目录
概念
- 在⼀个程序⾥的⼀个执⾏路线就叫做线程(thread)。更准确的定义是:线程是"⼀个进程内部 的控制序列"
- ⼀切进程⾄少都有⼀个执⾏线程
- 线程在进程内部运⾏,本质是在进程地址空间内运⾏
- 在Linux系统中,在CPU眼中,看到的PCB都要⽐传统的进程更加轻量化
- 透过进程虚拟地址空间,可以看到进程的⼤部分资源,将进程资源合理分配给每个执⾏流,就形 成了线程执⾏流
理解
1.
进程是承担分配系统资源的基本实体。
线程是cpu调度的基本单位。
一个进程可以有多个线程。
2.
线程强调共享,它共享进程内的资源,但也有自己的私有资源。
共享资源有:代码段(一个函数的多线程并发执行),堆(动态分配的内存),全局变量和静态变量,文件描述符表和打开的文件等等。
私有资源有:独立的栈结构( 每个线程有自己独立的栈空间,用于存储局部变量、函数调用参数和返回地址,不同线程的栈是隔离的,避免互相干扰。 ),一组寄存器中的上下文数据( 线程切换时,寄存器的值会被保存和恢复 )。
3.
语言层面怎么实现线程?
线程是个执行流的概念,只需要让线程执行程序中的不同函数即可,这个函数就是线程的入口。也就是代码区的划分,将来让线程执行不同的代码块即可,这也决定了线程也有自己独立的栈空间,用来存放局部变量的。
4.
怎么共享资源的?
线程也要管理起来,也是pcb数据结构管理起来,每个线程都有一个pcb,共同指向进程的虚拟地址空间,未来通过虚拟地址和页表访问物理内存,这样不就实现了资源的共享!
5.
线程设计?其他平台?linux,windows?
在windows中,是用TCB内核数据结构来管理线程的,而linux是直接复用进程,直接使用PCB(task_struct)来管理线程的,这样,进程的内核代码可以全部复用,所以说,在linux中,没有线程的概念,只有轻量级进程(LWP,实际上是一个task_struct的实例)的概念。
进程就是多个PCB和代码和数据,而线程其实就是单个PCB和代码和数据。
进程强调独占资源,部分共享(比如进程间通信的时候)。
线程强调共享资源,部分独占。

分页式存储管理
虚拟地址和⻚表的由来
思考⼀下,如果在没有虚拟内存和分⻚机制的情况下,每⼀个⽤⼾程序在物理内存上所对应的空间必 须是连续的,如下图:

因为每⼀个程序的代码、数据⻓度都是不⼀样的,按照这样的映射⽅式,物理内存将会被分割成各种 离散的、⼤⼩不同的块。经过⼀段运⾏时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎⽚的形式存在。
怎么办呢?我们希望操作系统提供给⽤⼾的空间必须是连续的,但是物理内存最好不要连续。此时虚 拟内存和分⻚便出现了,如下图所⽰:

把物理内存按照⼀个固定的⻓度的⻚框进⾏分割,有时叫做物理⻚。每个⻚框包含⼀个物理⻚ (page)。⼀个⻚的⼤⼩等于⻚框的⼤⼩。⼤多数 32 位 体系结构一般⽀持 4KB 的⻚,⽽ 64 位 体系结构一般支持8KB 的⻚。区分⼀⻚和⼀个⻚框是很重要的:
- ⻚框是⼀个存储区域;
- ⽽⻚是⼀个数据块,可以存放在任何⻚框或磁盘中
我们记得,磁盘天然就是4KB单位存储的,无论是文件属性还是内容,而现在,物理内存也是以4kb单位划分的,其实,内存就是以4kb为单位进行IO的。
有了这种机制,CPU便并⾮是直接访问物理内存地址,⽽是通过虚拟地址空间来间接的访问物理内存 地址。所谓的虚拟地址空间,是操作系统为每⼀个正在执⾏的进程分配的⼀个逻辑地址,在32位机 上,其范围从0~4G-1。
操作系统通过将虚拟地址空间和物理内存地址之间建⽴映射关系,也就是⻚表,这张表上记录了每⼀ 对⻚和⻚框的映射关系,能让CPU间接的访问物理内存地址。
总结⼀下,其思想是将虚拟内存下的逻辑地址空间分为若⼲⻚,将物理内存空间分为若⼲⻚框,通过 ⻚表便能把连续的虚拟内存,映射到若⼲个不连续的物理内存⻚。这样就解决了使⽤连续的物理内存 造成的碎⽚问题。
物理内存管理
假设⼀个可⽤的物理内存有 4GB 的空间。按照⼀个⻚框的⼤⼩ 4KB 进⾏划分, 4GB 的空间就是 4GB/4KB = 1048576 个⻚框。有这么多的物理⻚,操作系统肯定是要将其管理起来的,操作系统 需要知道哪些⻚正在被使⽤,哪些⻚空闲等等。
1.
内核使用struct page结构体管理内存页框的,最终就可以使用struct page mem[1048576]数组来统一管理,每个page都会有下标,一般多数物理内存起始地址都是0x0(也就是地址从0开始),所以每个页框的起始地址就是下标*4KB, 而具体的某个物理内存地址就等于某个页框起始地址+偏移量即可!
2.
申请物理内存在做什么?
在mem数组中查找没有被使用的页框,改页框属性(其实就是改标志位), 然后建立映射(建立内核数据结构的对应关系)。
页表
页表中的每一个表项,指向一个物理页的开始地址。在32位系统中,虚拟内存的最大空间是4GB,
这是每一个用户程序都拥有的虚拟内存空间。既然需要让4GB的虚拟内存全部可用,那么页表中就需要能够表示这所有的4GB空间,那么就一共需要4GB/4KB=1048576个表项。如下图所示:

⻚表中的物理地址,与物理内存之间,是随机的映射关系,哪⾥可⽤就指向哪⾥(物理⻚)。虽然最终使 ⽤的物理内存是离散的,但是与虚拟内存对应的线性地址是连续的。
1.
问题:在页表中,为什么要这样设计,只存页框起始地址?为什么不一个虚拟地址和一个物理地址映射呢?
假设物理内存4GB,每个字节都有地址,就有4x1024x1024x1024=4,294,967,296字节,也就有这么多个地址,而页表也要存下来,每个地址占四个字节,总共需要4,294,967,296x4=17,179,869,184字节=16GB,而这只是对应的一个地址,而虚拟地址和物理地址是一一对应的,那么就需要16x2=32GB,这个页表太大了!!!
所以,我们页表只存下页框起始地址:
页框起始地址=数组下标x4kb。而有 4GB/4KB=1048576个表项,每个表项都要一个起始地址,一个地址4字节,那么,这一个表就只需要1048576x4=4,194,304字节=4MB,而且这只是所有物理内存都使用了的前提下,正常来说用不到4MB,所以设计可行!
但是这样仍然有问题:
页表占总空间大小为1048576x4=4,194,304字节=4MB,也就是说页表本身就要占用4MB/4KB=1024个物理页,它会有哪些问题:
- 回想⼀下,当初为什么使⽤⻚表,就是要将进程划分为⼀个个⻚可以不⽤连续的存放在物理内存 中,但是此时⻚表就需要1024个连续的⻚框,似乎和当时的⽬标有点背道⽽驰了......
- 此外,根据局部性原理可知,很多时候进程在⼀段时间内只需要访问某⼏个⻚就可以正常运⾏ 了。因此也没有必要⼀次让所有的物理⻚都常驻内存。
解决需要⼤容量⻚表的最好⽅法是:把⻚表看成普通的⽂件,对它进⾏离散分配,即对⻚表再分⻚, 由此形成多级⻚表的思想。
为了解决这个问题,可以把这个单⼀⻚表拆分成 1024 个体积更⼩的映射表。如下图所⽰。这样⼀ 来,1024(每个表中的表项个数)*1024(表的个数),仍然可以覆盖 4GB 的物理内存空间。

⼀个应⽤程序是不可能完全使⽤全部的4GB空间的,也许只要⼏⼗个⻚表就 可以了。例如:⼀个⽤⼾程序的代码段、数据段、栈段,⼀共就需要 10 MB 的空间,那么使⽤ 3 个 ⻚表就⾜够了。
页目录结构
到⽬前为⽌,每⼀个⻚框都被⼀个⻚表中的⼀个表项来指向了,那么这 来。管理⻚表的表称之为⻚⽬录表,形成⼆级⻚表。如下图所⽰:

- 所有⻚表的物理地址被⻚⽬录表项指向
- ⻚⽬录的物理地址被CR3 寄存器指向,这个寄存器中,保存了当前正在执⾏任务的⻚⽬录地址。
所以操作系统在加载⽤⼾程序的时候,不仅仅需要为程序内容来分配物理内存,还需要为⽤来保存程 序的⻚⽬录和⻚表分配物理内存。
两级⻚表的地址转换
以32位机器为例,某个地址有32位比特位为例:
前10位比特位表示页表地址存入页目录中,中低10位表示页框起始地址存入页表,低12位为页内偏移。

1.
前10位取值个数(2^10)为[0,1023],总共1024种取值,表示每个页表的起始地址存入页目录表中,中10位取值个数(2^10)为[0,1023],总共1024种取值,表示页框的起始地址存入页表中,也就是说,最多可以建立1024x1024=1,048,576个页框的映射关系,而先前说映射全部物理空间有1048576个页框,一切都对上!
低12位取值个数(2^12)为[0,4095],总共4096种取值,表示页内偏移量,而一个页框4KB,每个字节要有一个地址,也就是说要有4x1024=4096个虚拟地址与之对应,而页内偏移刚好有4096个取值,一切对上!
2.
也就是说,物理地址=页框起始地址+页内偏移。那么,访问物理内存,其实就是先查找虚拟地址对应的页框,根据低12位,作为页内偏移,访问具体字节。所以进程,它有一张页目录+n张页表。
3.
申请内存其实是在,查找mem数组,找到没有被使用的page页框,由下标x4KB,找到页框起始地址,写入页表构建页框映射关系!
4.
从硬件上讲:
CR3寄存器存着页目录的物理地址,MMU寄存器负责虚拟地址到物理地址的转换,比如:要查某个虚拟地址,MMU就会获取CR3中的页目录物理地址,开始多级页表遍历。
单级⻚表对连续内存要求⾼,于是引⼊了多级⻚表,但是多级⻚表也是⼀把双 刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。
解决方法:


线程的资源划分,其实就是划分虚拟地址空间,本质上就是划分页表,线程的资源共享,其实就是虚拟地址空间的共享,本质上是页表的共享。
线程优缺点
优点:

缺点:


linux进程vs线程



线程控制
查进程指令可以使用:
ps axj
查线程指令可以使用:
ps -aL
POSIX线程库
- 与线程有关的函数构成了⼀个完整的系列,绝⼤多数函数的名字都是以"pthread_"打头的
- 要使⽤这些函数库,要通过引⼊头文件<pthread.h>
- 链接这些线程函数库时要使⽤编译器命令的"-lpthread"选项
1.
这是个第三方库,为什么要引入第三方库?
因为用户只认线程,而linux中没有线程的概念,只有轻量级进程的概念,这个第三方库是将轻量级进程封装起来的,给用户一批一些线程的接口。
2.
linux中,在进程中,每个task_struct都会有一个pid和TID属性,同一个进程中的线程pid相同,而TID不同,TID属于内核概念,由内核分配。
3.
Linux 中所谓的"轻量级进程"(LWP)本质上就是 内核线程,它是操作系统调度的基本单位。LWP 并不是一个独立于
task_struct
的结构体,而是task_struct
本身的一个实例。cpu调度的时候看的就是一个个LWP.
4.
而第三方库中也有一个线程ID的概念,是个
pthread_t
类型,它通常定义为unsigned long类型,实际上是个虚拟地址,
通过这个地址,(第三方库的概念)可以找到关于这个线程的基本信息,包括线程ID,线程栈,寄存器等属性。
创建线程

错误检查:
- 传统的⼀些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指⽰错误。
- pthreads函数出错时不会设置全局变量errno(⽽⼤部分其他POSIX函数会这样做)。⽽是将错 误代码通过返回值返回
- pthreads同样也提供了线程内的errno变量,以⽀持其它使⽤errno的代码。对于pthreads函数的 错误,建议通过返回值业判定,因为读取返回值要⽐读取线程内的errno变量的开销更⼩
线程终⽌
如果需要只终⽌某个线程⽽不终⽌整个进程,可以有三种⽅法:
- 从线程函数return。
- 线程可以调⽤pthread_exit终⽌⾃⼰,不能使用exit函数终止。
- ⼀个线程可以调⽤pthread_cancel终⽌同⼀进程中的另⼀个线程。

需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是⽤malloc分配的, 不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

线程等待
为什么需要线程等待?
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
- 创建新的线程不会复⽤刚才退出线程的地址空间。

调⽤该函数的线程将挂起等待,直到id为thread的线程终⽌。thread线程以不同的⽅法终⽌,通过 pthread_join得到的终⽌状态是不同的,总结如下:

代码:
void* routine(void* agrs)
{
std::string name=(const char*)agrs;
while(true)
{
std::cout<<"我是新线程:name:"<<name<<std::endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, routine, (void *)"thread-1");
if (n == 0)
std::cout << "pthread create success" << std::endl;
while (true)
{
std::cout << "我是主线程...." << std::endl;
sleep(1);
}
return 0;
}

class Task
{
public:
Task(int a, int b) : _a(a), _b(b) {}
int Execute(){return _a + _b;}
~Task() {}
private:
int _a;
int _b;
};
class Result
{
public:
Result(int result) : _result(result){}
int GetResult() { return _result; }
~Result() {}
private:
int _result;
};
//1. main函数结束,代表主线程结束,一般也代表进程结束
//2.新线程对应的入口函数,运行结束,代表当前线程运行结束
void* routine(void* agrs)
{
int cnt=5;
while(cnt--)
{
std::cout<<"我是新线程"<<std::endl;
sleep(1);
}
//其实就是强转成Task*类型
Task* t=static_cast<Task*>(agrs);
Result* res=new Result(t->Execute());
return res;
}
int main()
{
pthread_t tid;
Task* t=new Task(10,20);
//给线程传递的参数和返回值,可以是任意类型(包括对象)
int n = pthread_create(&tid, nullptr, routine, t);
if (n == 0)
std::cout << "pthread create success" << std::endl;
int cnt=5;
while (cnt--)
{
std::cout << "我是主线程...." << std::endl;
sleep(1);
}
Result* ret=nullptr;
//ret拿到线程退出设定的返回值
pthread_join(tid,(void**)&ret);//注意2级指针
n=ret->GetResult();//拿到计算结果
std::cout << "新线程结束, 运行结果: " << n << std::endl;
return 0;
}

线程分离
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进⾏pthread_join操作,否则 ⽆法释放资源,从⽽造成系统泄漏。
- 如果不关⼼线程的返回值,join是⼀种负担,这个时候,我们可以告诉系统,当线程退出时,⾃ 动释放线程资源。
- 可以是线程组内其他线程对⽬标线程进⾏分离,也可以是线程⾃⼰分离。
- joinable和分离是冲突的,⼀个线程不能既是joinable⼜是分离的。

代码:
const int num = 5;
void *routine(void *agrs)
{
std::string name=static_cast<const char*>(agrs);
delete (char*)agrs;//名字拿到了,可以释放了
int cnt=3;
while(cnt--)
{
std::cout<<"new 线程名字:"<<name<<std::endl;
sleep(1);
}
return nullptr;
}
int main()
{
std::vector<pthread_t> tids;
for (int i = 0; i < num; i++)
{
pthread_t tid;
//char id[64];//不可以这样写,这是在栈上开辟
char *id = new char[64];//要在堆上开辟
snprintf(id, 64, "thread-%d", i);
int n = pthread_create(&tid, nullptr, routine, id);
if (n == 0)
tids.push_back(tid);
else
continue;
}
for (int i = 0; i < num; i++)
{
int n = pthread_join(tids[i], nullptr);
if (n == 0)
std::cout << "等待成功" << std::endl;
}
return 0;
}

线程ID及进程地址空间布局
1.
先前说过,使用线程,都是使用的是第三方库,linux下无线程概念,只有轻量级进程概念,第三方库是封装轻量级进程来实现线程的。
2.
也就是说库中会存在多个封装好的线程,库中也要将其管理起来,我们称之为tcb,tcb中管理着一个线程的基本属性,如线程id,独立栈结构等等。
3.
我们知道,可执行程序的加载,要将动态库加载到内存且映射到当前进程地址空间处的映射区,所以说将来库中的tcb会被加载到映射区。
4.
当使用pthread_create创建线程时,会做俩件事:
1:在库中创建管理块,块中有线程tcb,线程局部存储,线程栈,创建好之后,会将线程id写到自己的tcb中,当线程执行完,会将自己的返回值写入自己tcb中的void* ret属性中,最后,主线程join回收时,会在这个线程的tcb中拿到线程id回收这个线程管理块,并将线程返回值带出来。
2:在内核中,创建轻量级进程,创建对应的pcb,调用系统调用clone,会将线程函数入口,线程栈地址写入到pcb中,将来pcb调度这个线程,就会从这个函数入口开始执行,线程临
我们下期见!!!