一个进程有对应PCB、虚拟地址空间、还有对应用户级和内核级页表,而在页表中,除了之前说的对应物理地址映射,还有其它属性,比如是否命中、RWX权限、U/K权限(这个代表映射的是用户的,还是内核的),不管是用户级的还是内核级的页表,每一条目属性都是一样的,因此OS还需要通过某种方式将页表每个段进行描述组织管理。
那么就可以明白,当运行以下代码的时候,因为这个字符串保存在字符常量区,只读不写,当对其进行写入时,就需要进行从虚拟地址到物理地址的转换,而在查页表发现没有写入权限,对应MMU也就发生硬件异常,OS收到这个异常就向对应进程发送11号信号,进程就会在合适的时候处理默认信号,也就中止进程。
char *str = "hello world";
*str = 'H';
那么页表是如何将虚拟地址划分到物理地址的?
页表肯定不简单,如果页表一个条目单纯保存着虚拟地址和映射的物理地址,那么32位下,2^32个地址光存储地址就要一个很大的占用空间。
其实,页表在进行线性地址到物理地址转换时,也不仅仅是通过软件的方式进行转换,而是通过页表+MMU,软硬件结合的方式进行转换的。(内存控制单元MMU,通过一种分段单元的硬件电路把一个逻辑地址转换成线性地址,接着通过分页单元的硬件电路把线性地址转换成一个物理地址)
当然在64位下,这个划分方案会有更多级。
如何看待地址空间和页表:
1、地址空间是进程能看到的资源窗口。
2、页表决定,进程真正拥有资源的情况。(前面说过,虚拟地址空间的4G是OS给进程画的大饼,实际拥有的资源得看页表映射了多少线性地址到物理地址,由映射关系决定)
3、合理的对地址空间+页表进行资源划分,就能对一个进程的所有资源进行分类。(地址空间的多种区域通过对应页表映射,也就将物理空间进行了分类)
不同平台对应线程实现策略是不一样的,在这里只谈Linux的线程实现策略。
前面说到了虚拟地址空间决定了进程能看到的主体资源,其实除了地址空间还有文件资源,这个先不管。
一个进程的创建伴随着PCB、虚拟地址空间、页表的创建以及加载代码和数据到内存。
在进程说过,进程可以把自己的代码划分一个部分,让另一个执行流去执行(比如通过fork创建子进程执行同一段代码),这里的另一个执行流是一个子进程,也是需要创建对应PCB、虚拟地址空间、页表的。
进程看到的资源是一整个地址空间,那么如果能将地址空间拆分成多个部分,尤其是代码区拆分成多个子区域,然后父进程再创建"进程",这次只创建对应的PCB,不会创建额外的地址空间、页表,并且这些PCB都指向父进程也指向的同一个地址空间,然后再通过某种方式把地址空间资源若干的划分给对应的PCB,让这些进程执行一部分代码和访问一部分资源。
综上,这种只创建PCB,让父进程给它分配资源的这种执行流就叫做线程。因为可以通过虚拟地址空间+页表方式对进程进行资源划分,单个"进程"执行力度,一定要比之前的进程要细。
因此,一个进程内部就有了多个执行流,而当创建一个进程时的最开始的执行流,可以称为主执行流。
如果OS真的为线程设计专门的数据结构(像进程设计PCB那样),OS用不用管理这个线程?
前面说,线程是进程内的一个执行流,执行流就一定是CPU来执行,CPU执行是以进程为单位。现在除了进程还有线程,并且线程还是在进程内的,那么CPU就要有意识的区分你是进程还是线程,也一定可以出现多个线程需要被处理,多个就意味着要被OS管理。
管理就意味着需要对线程进行描述,就要为线程设计专门的数据结构表示线程对象。(windows下是TCB
线程控制块),如果Linux也真这样干,进程间不但需要维护彼此的兄弟关系,进程内还要有多个线程,线程和线程的关系,线程和它属于的进程间的关系还要被维护,那么代码就非常复杂。而一个线程如果要被执行、被调度就得有(id,状态,优先级,上下文…),单纯从线程调度角度,线程和进程有很多的地方是重叠的!所以,linux设计者不想给Linux"线程"专门设计对应的数据结构,而是直接复用PCB,用PCB来模拟linux内核的"线程"。
Linux中没有真正意义的线程,用PCB模拟线程的好处?
简单!代码复用,维护成本大大降低。可靠高效。
一个进程是可以通过一些方式将资源划分成若干个块的。(粗暴理解:页表有很多的映射关系,可以将其中映射关系划分若干个归给好几个PCB)
当我们一旦创建了一堆"进程",只创建了对应PCB,那么站在CPU的角度,如何看待这里的一个一个PCB呢?
在未引入线程概念的时候,CPU对于进程调度是将进程的上下文(例如PCB、页表、地址空间数据)加载到寄存器进行调度的,而线程在linux中是一个用PCB模拟的执行流,和进程比只是量级更轻了。
因此,CPU在调度时,将每个PCB对应的执行流都看做是一个轻量级进程。
重构进程的概念 什么叫进程呢??
承担分配系统资源的基本实体。(拥有一堆PCB、虚拟地址空间、一堆页表、加载到内存的代码和数据,这些都要占用OS资源)
什么叫做线程呢?
CPU调度的基本单位!进程内的一个执行流,在进程的地址空间内运行,拥有该进程的一部分资源。
(CPU可能拿着多个线程中的一个PCB,也可能拿着一个进程一个执行流的PCB,但是当它拿到一个task_struct的时候都当作一个轻量级进程来看待。)
综上:
1、站在CPU的视角,每一个PCB对应的执行流,都可以称之为叫做轻量级进程。
2、Linux线程是CPU调度的基本单位,而进程是承担分配系统资源的基本单位。
3、进程是用来整体申请资源,线程用来伸手向进程要资源。(小组成员申请公司资源是以小组身份申请的,也就是以进程身份申请)。
线程的优点
创建一个新线程的代价要比创建一个新进程小得多
这很好理解,因为线程是一个以轻量级进程的方式作为进程的一个执行流,是通过PCB模拟的。
与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
1、切换页表 && PCB && 上下文 && 虚拟地址空间(进程)
2、切换PCB && 上下文 (线程)
主要是第三点。
3、线程切换cache不用太更新,但是进程切换,全部更新(cache就是一个比寄存器慢一点,比内存快一点的在CPU里的硬件级缓存,高速缓存。进程切换时,当前进程的缓存内容需要被保存,新进程的缓存内容需要重新加载。而线程的切换仅仅涉及到线程私有的栈空间和寄存器的保存和恢复,和缓存更新没有关系。)
线程占用的资源要比进程少很多
线程是进程中的一个执行流,所拥有的资源是进程的。
能充分利用多处理器的可并行数量
进程能,线程更能,因为线程的缓存不用更新。
在等待慢速I/O操作结束的同时,程序可指向其它的计算任务。
进程能,线程也能,只不过线程切换更高效,是进程的优点,也是线程的优点。
计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
计算密集型应用(用CPU资源,加密,解密,算法等),比如文件压缩,多个线程来分开压缩。
I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
I/0密集型应用(用外设资源,访问磁盘,显示器,网络等),比如用迅雷下电影的时候,能边下边看。
线程的缺点
性能的损失:线程不是越多越好,一般CPU核数和线程数最好一样的。(比如对于一个单核CPU,处理线程除了线程计算的成本,还一个是线程切换的成本,如果只有一个线程就只有计算的成本。)
健壮性降低:一个线程出问题可能影响其它线程
缺乏访问控制:对于全局变量,可能会因为一个线程的访问而影响另一个线程。
综合前面说的:
Linux内核中没有真正意义上的线程。Linux是用PCB来模拟线程的,是一种完全属于自己的一套线程方案。
那么Linux便无法直接提供创建线程的系统调用接口!而只能给我们提供创建轻量级进程的接口(比如系统接口clone,vfork创建的子进程和父进程共享地址空间)。
用户可用不惯这些,所以在用户和系统接口中间做了一层软件封装,就是用户级线程库pthread,也是linux的原生线程库,并且也是个动态库
总之,用户通过pthread库,会在用户空间创建一个TCB(线程控制块),用来保存线程的信息,并且会通过一些系统调用(比如clone)在内核空间中创建一个新的进程控制块(PCB),它和父进程PCB指向同一地址空间。这就是用户级线程和内核级线程一对一的方案。
在linux内核中,为了描述线程这一个概念,用了 LWP(轻量级进程) 来表示它,当CPU调度的时候,是以LWP为标识符表示特定一个执行流。(而主线程的LWP和整个进程的pid是一样的)
LWP:light weight process 轻量级进程
线程一旦创建,几乎所有的资源都是被所有线程共享的
线程共享同一地址空间,也因此,线程间交互数据是很容易的。
线程也一定要有自己私有的资源,那么什么资源应该是线程私有的呢?
1.PCB,保存了线程的信息(属于自己的id,优先级,状态,寄存器,堆栈指针等)
2.一定要有私有上下文结构。(保存了线程在运行时的寄存器、指令指针等CPU信息)
3.每一个线程都要有自己独立的栈结构。(保存自己的私有数据、栈帧)
这里有个问题,线程不是共享一个地址空间吗?为什么会有自己私有的栈结构?
用户创建线程是通过动态库中函数调用创建的,当程序运行,动态库加载到内存并映射到地址空间的共享区,当CPU调度主线程接着会在动态库中创建线程的TCB并且在内核创建对应的PCB,而在库中创建的TCB有各种属性、局部存储、线程栈。
而后面会说用户创建线程调用的函数,这个函数其中thread参数是一个输出型参数,当函数调用完,这个参数将返回一个指向线程TCB的地址。
线程局部存储:线程局部存储是一种机制,在使用它时,每个线程都会拥有一份自己的变量副本,这些变量可以通过在不同函数间共享,用于实现线程间数据共享。
线程独立栈:用于实现函数调用时的栈帧管理。(通过共享区映射到一份独立的内存空间中进行分配的)
前面说到了linux线程的方案,其实是一种用户层建立线程,内核层调度线程的方案。
用户使用线程的相关操作需要调用原生线程库(libpthread.so动态库),也就是说在编译的时候还要链接这个动态库。
创建线程
thread是一个输出型参数,用户需要将一个pthread_t(unsigned long int)的类型变量地址传进去,线程创建完后返回值会得到一个地址。
因为线程是在库中创建的,这个返回的地址会指向库中为线程建立的控制块,而在控制块中有着线程的各自属性、局部存储、独立栈空间。因此通过这个参数就能找到这些。(也就是上上张图说的)
attr 也是一个输出型参数,为了设置线程的属性,一般将attr设为NULL表示使用默认属性
start_routine 表示一个函数指针,指向新线程启动调用的函数,也是它的执行流。
arg 是给线程启动调用函数传的参数
返回值问题: 不像常规调用函数成功返回0,失败返回-1,而是直接返回对应错误码。
下面给一个实验代码
#include
#include
#include
#include
using namespace std;
//线程一旦创建,几乎所有的资源都是被所有线程共享的!!
string func()
{
return "我是一个独立的方法";
}
void* thread_routine(void* args)
{
const char* name = (const char*)args;
int cnt = 3;
while(cnt--)
{
cout << "我是一个线程:" << name << ":" << func() << endl;
sleep(1);
}
int* p = nullptr;
*p = 10;
}
int main()
{
//typedef unsigned long int pthread_t; 64位的unsigned int
pthread_t tid;
pthread_create(&tid, nullptr, thread_routine, (void*)"pthread one");
sleep(1);
while(true)
{
char tidbuffer[64];
snprintf(tidbuffer, sizeof tidbuffer, "0x%x", tid);
cout << "主线程运行中,创建的线程tid:" << tidbuffer << ":" << func() << endl;
sleep(1);
}
return 0;
}
运行结果:
这段代码可以说明两个问题:
1、通过主线程和其它进程共同都可以调用func函数来看,线程之间是可以共享一些资源的(其实大部分都可以共享)。
2、当一个线程出问题会影响其它线程(p空指针的解引用导致的段错误,这也体现了线程的健壮性低)。其实这也是因为信号是发给进程的,当线程出现异常,OS直接中止了整个进程
如果想要终止一个线程,除了等它运行完正常退出外,还能通过终止、取消函数使他终止。
参数retval:发生对应线程终止的返回值。
线程的等待:
线程也是要被等待的!如果不等待,线程TCB没有被释放,会造成类似僵尸进程的问题–内存泄漏
线程必须要被等待:
1.回收新线程的退出信息(可以不关注)
2.回收新线程的PCB等内核资源,防止内存泄漏 – 暂时无法查看
阻塞线程:
主线程等待释放所有的其它线程,直到回收退出的线程后,自己接着执行。
thread:对应线程的tid
retval:一个输出型参数,接受返回值
测试代码:
#include
#include
#include
#include
#include
#include
using namespace std;
class ThreadData
{
public:
int number;
pthread_t tid;
char namebuffer[64];
};
class ThreadReturn
{
public:
int exit_code;
int exit_result;
};
void* start_routine(void* args)
{
ThreadData* td = static_cast<ThreadData*>(args); //安全的进行强制类型转化
while(1)
{
cout << "new thread create success, name: " << td->namebuffer << endl;
pthread_exit((void*)td->number);
}
}
int main()
{
vector<ThreadData*> threads;
#define NUM 2
for(int i = 0; i < NUM; ++i)
{
ThreadData* td = new ThreadData();
td->number = i;
snprintf(td->namebuffer, sizeof(td->namebuffer), "%s:%d", "thread", i+1);
//将一个结构体对象传参
pthread_create(&td->tid, nullptr, start_routine, td);
threads.push_back(td);
sleep(1);
}
for(auto& iter : threads)
{
std::cout << "create thread: " << iter->namebuffer << " : " << iter->tid << " success " << endl;
}
for(auto& iter : threads)
{
void* ret = nullptr;
int n = pthread_join(iter->tid, &ret);
assert(n == 0);
cout << "join : " << iter->namebuffer << " success, number: " << (long long)ret << endl;
delete iter;
}
while(true)
{
cout << "new thread create success, name: main" << endl;
sleep(1);
}
return 0;
}
输出结果:
看到现象:
新线程退出后,主线程阻塞拿到线程的退出信息number。
那么返回值是如何接收的呢?
线程在退出之后,将返回值通过void*存储到了库中,阻塞函数join通过一个输出型参数,用二级指针传参保证到了库中通过一级指针数据赋值给它的解引用,从而拿到了数据。
还有一个问题,线程出异常,收到信号,整个进程都会退出,join不会接受退出信号。
因为pthread_join:默认就认为函数会调用成功!不考虑异常问题,异常问题是进程要考虑的问题
线程取消
发生一个取消请求给一个指定的线程。
值得注意的是:
线程是可以被其它线程取消的! 线程得跑起来才能被取消
线程如果是被取消的,退出码: -1(对应一个定义的宏:PTHREAD_ CANCELED)
稍微修改后
#include
#include
#include
#include
#include
#include
using namespace std;
class ThreadData
{
public:
int number;
pthread_t tid;
char namebuffer[64];
};
class ThreadReturn
{
public:
int exit_code;
int exit_result;
};
void* start_routine(void* args)
{
ThreadData* td = static_cast<ThreadData*>(args); //安全的进行强制类型转化
while(1)
{
cout << "new thread create success, name: " << td->namebuffer << endl;
sleep(1);
}
}
int main()
{
vector<ThreadData*> threads;
#define NUM 2
for(int i = 0; i < NUM; ++i)
{
ThreadData* td = new ThreadData();
td->number = i;
snprintf(td->namebuffer, sizeof(td->namebuffer), "%s:%d", "thread", i+1);
pthread_create(&td->tid, nullptr, start_routine, td);
threads.push_back(td);
sleep(1);
}
for(auto& iter : threads)
{
std::cout << "create thread: " << iter->namebuffer << " : " << iter->tid << " success " << endl;
}
sleep(3);
for(auto& iter : threads)
{
pthread_cancel(iter->tid);
std::cout << "cancel thread: " << iter->namebuffer << " : " << iter->tid << " success " << endl;
}
for(auto& iter : threads)
{
void* ret = nullptr;
int n = pthread_join(iter->tid, &ret);
assert(n == 0);
cout << "join : " << iter->namebuffer << " success, number: " << (long long)ret << endl;
delete iter;
}
while(true)
{
cout << "new thread create success, name: main" << endl;
sleep(1);
}
return 0;
}
一个新线程刚被创建默认是可被阻塞的(joinable),如果我们不需要关注一个线程的返回值,那么就没必要特意阻塞它,而是可以让线程运行完自己分离,并且自动释放资源。
参数就传一个线程ID。
返回值:成功返回0,错误返回一个错误码。
这里的线程ID指的是一个地址空间的指向,通过pthread_self可以返回当前线程的ID,注意这个不是LWP值。
#include
#include
#include
#include
#include
#include
using namespace std;
//全局变量设置线程局部存储,线程互相访问就不会影响了
__thread int g_val = 100;
const string GetId(const pthread_t& thread_id)
{
char tid[128];
snprintf(tid, sizeof tid, "0x%x", thread_id);
return tid;
}
void* start_routine(void* args)
{
const string threadname = static_cast<const char*>(args);
int cnt = 2;
while(1)
{
cout << threadname << " running " << GetId(pthread_self()) << " g_val:" << g_val << " &g_val:" << &g_val << endl;
g_val++;
sleep(1);
}
}
int tickets = 1000;
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, start_routine, (void*)"thread 1");
sleep(1); //主线程运行太快,让其等一下其它线程
//TODO
pthread_detach(tid); //分离
cout << "main thread running.... " << GetId(tid) << ":" << GetId(pthread_self()) << " g_val:" << g_val << " &g_val:" << &g_val<< endl;
//一个线程默认是joinable的,如果设置了分离,就不能够等待了,不然会报错
int ret = pthread_join(tid, nullptr);
cout << "result:" << ret << ":" << strerror(ret) << endl;
return 0;
}
运行结果:
关键点:
1、g_val在全局被声明称线程的局部存储变量,这就意味着不同线程用着独立的g_val。(上面不同地址也能说明)
2、主线程和其它线程的运行顺序是随机的,是不能确定的。
3、如果一个线程被分离后再阻塞,会报错。
本节完~