目录
线程的基本概念
**定义:**进程内部的一个控制序列称为线程
初步理解:

由进程引入:
我们知道创建一个进程,需要创建描述该进程的PCB,虚拟地址空间以及页表。进程想要访问外部资源,必须先访问虚拟地址通过页表映射去访问想访问的资源。也就是说虚拟地址就是进程看向外界资源的一个窗口。所以我们把进程称之为系统承担分配资源的基本实体。
而在linux平台下,线程就是进程的一个执行流分支,一个进程可以有多个线程,共同来访问该虚拟地址空间资源。
注意:
1.这些"线程"都是用进程模拟出来的,对于线程的创建只需要创建对应的pcb即可。这相比创建进程来讲更轻量化了所以称线程为"轻量级进程"。
2.这些线程去划分虚拟地址空间的时候,实际上就是访问不同的函数地址。
3.线程是进程的一个执行分支,所以线程才是cpu调度的基本单位。
4.线程对资源的划分其实也就是对虚拟地址空间的划分,也是对页表的划分。
线程VS进程

进程&线程
1.进程是资源分配的基本单位,线程是调度的基本单位
2.线程共享进程数据,但也拥有自己的⼀部分数据:线程ID,⼀组寄存器,栈,errno,信号屏蔽字,调度优先级
进程的多个线程共享
同⼀地址空间,Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调 用,定义⼀个全局变量,在各线程中都可以访问到。
分页式存储管理
虚拟地址和页表存在的必要性

以上是不存在虚拟地址和页表的情况,用户程序直接访问物理内存,因为每一个程序的代码,数据长度都是不一样的,按照这种映射方式,物理内将会被分割成各种离散的,大小不同的块。某些程序退出空间被回收也会造成物理内存大多都是碎片形式的。所以为了更好的使用内存,虚拟地址和页表就必然会存在。

如上图就是存在了虚拟地址空间和页表的情况下:os把物理内存分割为一个个固定长度的页框(4kb的物理页page)。(说明:页框是一个存储区域,页是一个数据块,可以防止页框中)。
至此用户程序如果在想访问物理地址,只需要访问自己的虚拟地址,通过页表映射便能间接连续访问物理资源。这样也解决了使用连续物理内存造成碎片问题。
物理内存的管理

上边我们讲为了避免内存碎片化,操作系统会把物理内存划分为一个个的页框,假设一个页框4kb,4GB的物理地址就有1048576个页框,操作系统要知道哪个页框没有,哪个正在被用的情况,所以操作系统需要把这些页框管理起来。
单个页框(struct_page)
单个页框也必须有他自己的描述结构struct_page。描述信心包括但不限于是否被使用,被谁使用,对应得虚拟地址等。
需要值得注意得是该描述结构体中并不需要秒速该页位于得物理地址,因为os是通过数据结构管理这些页的,通过各自页的下标即可找到对应的物理地址,所以没必要描述。
**结论:**我们平时所讲得申请物理内存本质就是在查空闲page,该page中得标志为(设置为使用),建立对应得数据结构。
页表
页表的存在让整个物理内存使用情况看起来更加的条理清楚了,对于虚拟地址通过页表映射到对应的物理地址,其实映射过程也是随机的哪里有空间就映射到哪里,只不过这个过程页表的使用是连续的,故而可以通过页表来对物理资源的情况来做梳理,让物理内存使用情况变得相对连续。
页表的设计模式
整个页表如果要全部映射地址空间要4GB,这显然是不可取的,我们可以把单一页表拆分为更多的映射表,依然可以覆盖更大的空间。

如上图表示,我们可以把一个逻辑地址拆分没10+10+12的模式,来看待这个二级页表。
其中:
前10位叫做页目录用来管理(0-1023)个页表,存放页表的地址。
中间10位用来表示页表,用来管理物理内存中划分的(4096个)page页框。
最后12位标识物理内存中4kb页框数据的偏移量,快速找到页框中对应的物理地址。
回答一些问题
至此我们可以理解很多之前专有名词:
1.CR3寄存器+MUMU可以完成虚拟地址->物理地址的映射。
CR3寄存器保存了当前进程硬件上下文,指向当前进程的页目录地址,能完成映射。
MMU:拿到虚拟地址和CR3确定的页表可以完成虚拟->物理的映射。
2.缺页异常
缺页异常产生原因是因为我们通过页表并不能找到对应的物理页,这时cpu无法获取数据救会产生一个缺页错误,也就是缺页中断了,这时整个系统模式会从用户态进入内核态,交给内核去处理对应的中断方法。内核会查找物理内存空白页,建立映射关系等操作。
3.如何理解new/malloc?
对于new/malloc,使用时,os并不会直接在物理内存申请内存。
new/malloc底层封装系统调用brk/mmap(更改数据段的大小),new/ma/只需要改变堆空间的地址即可,申请后没必要立马申请,当真正去使用访问这段空间是,os才会在物理内存真正申请这段内存。先在虚拟地址上申请空间,本质也叫os的延迟申请,延迟到真正使用的时候,可以提高os内存使用率。
4.如何理解写时拷贝?
fork之后,父子指向同一个页框,页框被设置为只读,当去修改的时候,os触发错误对整个页框进行写实拷贝。修改时指向新页框。
5.如何区分是缺页了,还是真的越界了?
**页号合法性检查:**操作系统处理中断 / 异常时,先检查虚拟地址的页号是否合法:页号合法但页面不在内存 → 缺页中断;页号非法 → 越界访问.
**内存映射检查:**检查虚拟地址是否在当前进程的内存映射范围内:地址在范围内但页面不在内存 → 缺页中断;地址不在范围内 → 越界访问。
小结论:所以执行流看到的资源,本质是:在合法的情况下,你拥有多少虚拟地址(进行页表转化),虚拟地址就是资源的代表页表当然也是。所以线程对资源的划分就是对虚拟地址的划分就是对页表的划分。
TLB快表
单级页表对连续内存要求高,于是引入了多级页表,但是多级页表在减少连续存储要求且减少存储空间的同时降低了查询效率,这是可以使用快表TLB(一块缓冲)来解决。

当 CPU 给 MMU 传新虚拟地址之后, MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存。但 TLB 容量比较小,难免发生 Cache Miss ,这时候 MMU 还有页表,在页表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录一下刷新缓存,供下次使用。
线程优点
1.创建一个新线程的代价要比创建⼀个新进程小得多
创建线程只需创建对应的pcb即可
2.线程之间的切换比进程之间的切换需要操作系统做的工作要少很多。
**进程切换:**cpu硬件上下文需要切换,CR3页表切换,地址空间切换,页表切换等。线程切换: 只需要切换对应的pcb结构的指向即可。
最主要的区别 是线程的切换虚拟内存空间依然是相同的,但是进程切换虚拟地址需要重新改变。这两种上下切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下
文数据,处理器中所有已经缓存的内存地址一瞬间都作废了。
还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲 TLB (快表)会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题,用户空间的cache也会改变。
3.线程占用的资源要比进程少很多。
4.能充分利用多处理器的可并行数量。多个cpu可以创建多个线程
5.在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。eg:这个线程io操作比较慢,其他线程继续工作。
6.计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。eg:计算一个任务,把计算任务拆成好份,把这几份分配给不同的线程,同步执行提升效率。
7.I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。eg:访问磁盘,下载,上传文件。多线程下载,第一线程下载1gb,多个线程可以下载同一个文件不同部份,提高效率。
线程缺点
1.性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计
算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指
的是增加了额外的同步和调度开销,而可用的资源不变(创建的线程较多,处理器少)。
2.健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者
因共享了不该共享的变量而造成不良影响的可能性是很大的,线程之间是缺乏保护,任何一个线程异常崩溃整个进程就崩溃(共享保护机制,同步互斥)。
线程异常
1.单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
2.线程是进程的执行分支,线程出异常,进程也就出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
线程控制
POSIX线程库初步理解
POSIX线程库又为pthread库,是遵循 POSIX 标准的跨平台 线程编程接口,也是 Unix/Linux 系统下多线程开发的核心工具。
要使用线程编程接口就必须引用<pthread.h>线程库,链接编译这些线程库函数时要使用"-lpthread"选项。
为什么pthread不是系统调用,而是单独设置的一种库?
我们知道在linux平台下线程是由进程模拟而产生的轻量级进程,所以在linux内核中并不存在线程的概念,只存在轻量级进程这个概念,但在用户层我们只关系线程而不关心什么叫轻量级进程,所以pthread库就是在用户和内核之间封装起来的一个库,里边提供一些相关线程的接口。所以phread库是在用户层实现的。
另一个原因是因为如果把pthread设置为系统调用那么会导致线程接口与具体内核绑定,无法实现跨平台兼容。用户层实现是屏蔽了不同内核底层的差异。
线程相关的函数接口
线程创建
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);相关参数:
pthread_t *thread:输出型参数,输出内容是创建的线程tid
pthread_attr_t *attr:线程属性,nullptr时默认属性
void *(*start_routine):线程执行时要使用的函数
void* arg:传递给执行函数的参数
返回值:
成功返回0,失败返回错误码
测试案例:创建一个线程,各自执行不同的工作
cpp
int cnt=10;
void *routine(void *args)
{
string name = (const char*)args;
while (true)
{
cout << "我是" << name << "线程,mypid:" << getpid()<<"cnt:"<<cnt<< endl;
sleep(1);
cnt++;
}
return nullptr;
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, routine, (void *)"thread-l");
if (n != 0)
{
perror("creat error");
exit(-1);
}
// 线程创建成功,主函数打印数据
while (true)
{
cout << "我是主线程,mypid:" << getpid() <<" cnt:"<<cnt<< endl;
sleep(1);
}
return 0;
}
//运行结果
/*
我是主线程,mypid:2647169 cnt:10
我是thread-l线程,mypid:2647169cnt:10
我是主线程,mypid:2647169 cnt:10
我是thread-l线程,mypid:2647169cnt:11
我是主线程,mypid:2647169 cnt:11
*/
线程创建之后,新创建的线程和主线程同时执行各自的方法流。
另外新创建的线程彼此之间共享整个虚拟地址空间(比如测试代码中的全局变量)。
使用ps -aL:可以查看除创建的新线程,其中LWP是轻量级进程的id号,cpu调度的基本单位就是LWP,两个线程共属一个进程pid相同。

这里获得的pid是一个很大的数字,显然不是lwp,这里的pid其实是用户成封装的线程地址(后边在线程地址空间会详细讲)

线程终止
线程终止不能调用exit(),exit退出是进程级别的,使用exit整个系统调用都会被终止,所以想要退出进程需要调用下边接口。
void pthread_exit(void *retval);
相关参数:
void *retval:退出信息
返回值
成功放回为0,失败为错误码
线程等待
成功创建线程后要等待线程,不然很有可能会变成僵尸进程。
等待的目标线程如果异常了整个进程就退出了(os收到信号直接处理了),所以join异常 无意义。
int pthread_join(pthread_t thread, void **retval);
相关参数:
pthread_t thread:等待进程的tid
void **retval:获取进程的退出信息
对于线程的退出信息可以如果不想接收,retval直接设置为nullptr就行。
但这个退出信息可以是任何类型
线程取消
int pthread_cancel(pthread_t thread);
pthread_t thread:要取消的线程id
线程分离
正常情况下新线程需要被等待(joinable),如果主线程不想关心新线程的退出状态可以把新线程设置为分离状态(!joinabe)。被分离的线程仍然在进程的虚拟地址空间中,进程所有的资源,被分离的线程依旧可以访问操作。但被分离后主线程就不需要等待(join)新进程了。
int pthread_detach(pthread_t thread);
相关参数
pthread_t thread:要分离线程的tid;
返回值
成功放回为0,失败为错误码
线程ID获取
pthread_t pthread_self(void);//获取当前线程的tid,谁调获得谁的。
线程传参和返回值
创建线程时给线程传参和返回值都可以时是任意类型。
eg:向新线程中传入一个类对象(处理动作)。
cpp
class Task
{
public:
Task(int a, int b) : _a(a), _b(b) {};
int Excute()
{
return _a + _b;
}
~Task() {};
private:
int _a;
int _b;
};
void *routine(void *t)
{
Task *T = static_cast<Task *>(t);
int cnt = 10;
cout << "计算结果为:" << T->Excute() << endl;
return (void *)T->Excute();
}
int main()
{
Task *t = new Task(10, 20);
pthread_t tid;
pthread_create(&tid, nullptr, routine, t);//传入一个对象
void *ret = nullptr;
pthread_join(tid, &ret);//返回得到一个任务结果
cout << "新线程退出信息ret:" << (long long)ret << endl;
return 0;
}
//计算结果为:30
新线程退出信息ret:30
线程地址空间
对于线程是用户层实现的一种库,他本质也是一个elf动态库,所以进程使用pthread库创建线程的时候也需要把这个库加载到自己程序对应的虚拟地址空间中,而且被整个地址空间所共享(加载原理跟动态库加载一样)。这样进程去使用pthread相关接口时直接可以去对应区域进行调用。

对于线程的管理当然也是必不可少的,因为线程的概念时在用户层产生的,并在pthread库中维护的,所以对于线程的描述组织也一定就在pthread中。
实际上在pthread创建的线程结构如下:

对于structl_pthread就是我们创建线程的TCB的起始地址,这个地址正是我们前边所说的pthread_create的第一个参数tid---->用户及线程ID。
到此我们也不难理解,线程处理完成后并没有立即释放自己的空间,而是把自己的退出信息存储在自己的TCB中,主程序通过join可以在TCB中获取线程的退出信息。
线程局部存储:每个线程虽然说可以共享虚拟地址空间,但一个线程如果不想让其他线程看到自己相使用的变量,只需加上关键字__thread即可。线程的局部存储可以只能存储内置类型和部分指针。
用户级线程创建和LWP的联动
用户级线程的创建就是创建自己的TCB结构。
LWP是内核底层创建的轻量级进程。所为轻量级进程的创建实际就是底层通过clone函数进行拷贝的现有进程。
int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...
/* pid_t *parent_tid, void *tls, pid_t *child_tid */ );
//只需要知道有这个函数就行,不必知道怎么用。
int (*fn)(void *):创建的线程要执行的方法
void *stack:创建线程执行方法的栈结构
简单总结一下创建线程究竟是在干什么
我们在使用pthread_create时首先一方面会在pthread库中创建管理块pcb,另一方面会在内核中创建轻量级进程(通过系统调用clone的方式,这个过程os会告诉clone执行什么方法,用哪个栈结构)。所以创建出的轻量级进程会执行相应的方法,使用相应的栈空间。在用户层也会返回控制块的起始地址。

线程封装
对线程封装就是使用联系一下相关接口(仅供自己复习使用)。
cpp
//.pthread.hpp
#include <iostream>
#include <pthread.h>
#include <string>
#include<unistd.h>
#include<functional>
using namespace std;
static int number = 1;
class Thread
{
using func_t=function<void()>;
public:
Thread(func_t func)
: _tid(0), _isdetach(false),_isrunning(false),res(nullptr),_func(func) //传来方法
{
_name = "thread" + to_string(number++);
}
void Detach(){
//线程分离
if(_isdetach){
return;
}
if(_isrunning){
pthread_detach(_tid);
cout<<"分离成功"<<endl;
}
Enabldetach();
}
void Enabldetach()
{
_isdetach = true;
}
void Enablerunning(){
_isrunning=true;
}
static void *routine(void* args){//routin属于类内成员函数,有两个参数,一个是this指针//加static可以解决 主要是让类外提供方法
//static 可以解决不传this指针,但无法访问类内成员变量,所以传参的时候把this传进来
Thread* self=static_cast<Thread*>(args);
self->Enablerunning();
self->_func();//类内可以访问回调函数
return nullptr;
}
bool Start()
{
// 线程创建
int n = pthread_create(&_tid, nullptr, routine,this);
if (n != 0)
{
perror("pthread_create error");
return false;
}
cout << _name<<":create sucess!!!" << endl;
// 线程创建成功
//开启进程
return true;
}
void Stop(){
//终止一个进程
if(!_isrunning){
cout<<"没有线程启动"<<endl;
exit(-1);
}
//取消线程
int n=pthread_cancel(_tid);
if(n!=0){
perror("cancle erroe");
exit(-1);
}
cout<<"取消成功"<<endl;
_isrunning=false;
}
void Join(){
if(_isdetach){
//如果是分离的直接退出
cout<<"线程被分离,不需要join"<<endl;
return;
}
//线程等待
int n=pthread_join(_tid,&res);
if(n!=0){cout<<"等待失败"<<endl;exit(-1);}
cout<<"join sucess!!!"<<endl;
}
~Thread() {}
private:
pthread_t _tid;
string _name;
bool _isdetach;
bool _isrunning;
void* res;
func_t _func;
};
cpp
//.pthread.cpp
#include"thread.hpp"
using namespace std;
int main(){
Thread t([](){
while(true){
cout<<"我是一个新线程"<<endl;
sleep(1);
}
});
t.Start();
sleep(5);
t.Detach();
sleep(5);
t.Stop();
sleep(5);
t.Join();
return 0;
}
