★ Linux ★ 线程概念与控制
Ciallo~(∠・ω< )⌒☆ ~ 今天,我将和大家一起学习 linux 的线程概念与控制~
❄️❄️❄️❄️❄️❄️❄️❄️❄️❄️❄️❄️❄️❄️
澄岚主页:椎名澄嵐-CSDN博客
Linux专栏:★ Linux ★ _椎名澄嵐的博客-CSDN博客
❄️❄️❄️❄️❄️❄️❄️❄️❄️❄️❄️❄️❄️❄️
目录
壹 Linux线程概念
1.1 初识线程
1.2 分页式存储管理
1.2.1 虚拟地址和页表的由来
1.2.2 物理内存管理
1.2.3 页表
1.2.4 两级页表的地址转换
1.2.5 缺页异常
1.3 线程理解
1.3.1 线程的优点
1.3.2 线程的缺点
1.3.3 线程异常
1.3.4 线程用途
1.4 进程和线程
贰 Linux线程控制
2.1 POSIX线程库
2.2 Linux线程控制接口
2.2.1 pthread_create
2.2.2 pthread_self
2.2.3 pthread_join
2.2.4 pthread_exit & pthread_cancel
2.2.5 分离线程
2.2.6 多线程创建方法
叁 线程ID及进程地址空间布局
肆 线程封装
~ 完 ~
壹 Linux线程概念
1.1 初识线程
进程和线程在书中的概念为:
进程:内核数据结构+数据和代码(执行流)
线程:进程内部的一个执行分支(执行流)
在内核与资源角度的概念为:
进程:承担分配系统资源的基本实体
线程:CPU调度的最小单位
进程访问大部分资源,都是通过地址空间访问的。地址空间(mm_struct)相当于一个窗口,每创建一个进程就创建一个窗口。
那如果有几个task_struct都指向同一个mm_struct呢~
将资源分配给不同的task_struct就可以模拟出线程了。这样每个task_struct都可以运行代码区的一部分代码。划分地址空间,就是划分虚拟地址,就是划分页表,访问物理内存的一部分数据。
结论:
1. Linux的线程可以用进程来模拟。
2. 对资源的划分,本质是对地址空间虚拟地址范围的划分。虚拟地址也是资源。
3. 每一个函数就是一段虚拟地址空间段,代码区划分就是让线程执行不同的函数。
4. 进程不只有task_struct,如下紫框和一起才是进程。下图是一个单进程,只有一个线程的进程。所以线程肯定多于进程。
5. Linux线程的实现是通过复用进程的代码。Windows是通过TCB链表的把线程管理起来。
在Linux操作系统视角,线程是执行流,每个线程执行部分进程的代码,所以线程的执行流<进程。
在硬件CPU视角,线程是轻量级进程。
1.2 分页式存储管理
1.2.1 虚拟地址和页表的由来
如果在没有虚拟内存和分页机制的情况下,因为每一个程序的代码、数据长度都是不一样的,物理内存将会被分割成各种离散的、大小不同的块。经过一段运行时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎片的形式存在。
操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。
将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干页框,通过页表便能把连续的虚拟内存,映射到若干个不连续的物理内存页。
1.2.2 物理内存管理
磁盘和物理内存是通过4K为单位进行IO交换的。这些内存块叫做页框或页帧。
如果一个电脑内存4GB,其中就会有4GB/4KB,100万个页框。这并不是磁盘划分的,而是OS划分的,所以要管理页框。把页框的管理转换为数组的管理:struct page mem[1048576],每个page都有下标,那么index*4KB就可以得出这个page的起始地址。
具体物理地址 = 起始物理地址 + 页内偏移地址,要用一个资源时会把它所在的page全部写时拷贝,增加效率。
内核用 struct page 结构表示系统中的每个物理页,出于节省内存的考虑, struct page 中使
用了大量的联合体union。
申请物理内存就是在 1. 查数组改page,2. 建立内核数据结构的对应关系。
1.2.3 页表
如上图所示,32位的虚拟地址被拆成3份,前10位给页目录作为0-1023的数组下标,数组内容位下级页表地址,指向最多1024个页表。页表中保存页的地址,指向物理内存。
CPU中的CR3为当前硬件的上下文,当进程切换,页目录也就切换了。MMU把虚拟内存转换成物理内存:查找页框 + 进行偏移。
要点:
1. 当发现虚拟地址合法,有页目录,但没有页表,说明磁盘还未拷贝到内存,虚拟地址->物理地址转化失败,触发中断,执行申请算法,申请内存:
查找数组->查找没有被使用的page->page index->获得页框地址放进来~
2. 写时拷贝,缺页中断,内存申请,都有可能要建立新的页表和新的映射关系的操作。
3. 进程中有一张页目录和n张页表构建的映射体系,虚拟地址是索引,物理地址页框是目标。
虚拟地址(低12位)+ 页框地址 = 物理地址
4. 页框大小为4KB,2的12次方刚好覆盖。
执行流看到资源的多少本质是虚拟地址的多少。虚拟地址空间mm_struct,vm_area_struct就是资源的统计数据和整体数据。
资源划分 -> 地址空间划分
资源共享 -> 虚拟地址共享
1.2.4 两级页表的地址转换
将逻辑地址( 0000000000,0000000001,11111111111 )转换为物理地址的过程:
1. 在32位处理器中,采用4KB的页大小,则虚拟地址中低12位为页偏移,剩下高20位给页表,分成两级,每个级别占10个bit(10+10)。
2. CR3 寄存器读取页目录起始地址,再根据一级页号查页目录表,找到下一级页表在物理内存中
存放位置。
3. 根据二级页号查表,找到最终想要访问的内存块号。
4. 结合页内偏移量得到物理地址。
以上其实就是 MMU 的工作流程。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 处理。
- Hard Page Fault 也被称为Major Page Fault ,翻译为硬缺页错误/主要缺页错误,这时物理内存中没有对应的物理页,需要CPU打开磁盘设备读取到物理内存中,再让MMU建立虚拟地址和物理地址的映射。
- Soft Page Fault 也被称为Minor Page Fault ,翻译为软缺页错误/次要缺页错误,这时物理内存中是存在对应物理页的,只不过可能是其他进程调入的,发出缺页异常的进程不知道而已,此时MMU只需要建立映射即可,无需从磁盘读取写入内存,一般出现在多进程共享内存区
域。 - Invalid Page Fault 翻译为无效缺页错误,比如进程访问的内存地址越界访问,又比如对空指针解引用内核就会报segment fault 错误中断进程直接挂掉。
1.3 线程理解
线程进行资源划分,本质是地址空间的划分,获得一定范围的合法虚拟地址,也就是划分页表。
线程进行资源共享,本质是地址空间的共享,对页表条目的共享。
1.3.1 线程的优点
创建一个新线程的代价要比创建一个新进程小得多;线程占用的资源要比进程少;能充分利用多处理器的可并行数量;在等待慢速I/O操作结束的同时,程序可执行其他的计算任务(这条也是进程优点)。
计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
重要:与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
- 另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲TLB (快表)会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件cache。
所以进程切换,TLB和Cache会失效,下次运行要重新缓存,而线程切换不要。
1.3.2 线程的缺点
性能损失(线程太多时):一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护
的。
缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。(也是优点)
编程难度提高:编写与调试一个多线程程序比单线程程序困难得多。
1.3.3 线程异常
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程
终止,该进程内的所有线程也就随即退出。
1.3.4 线程用途
合理的使用多线程,能提高CPU密集型程序的执行效率
合理的使用多线程,能提高IO密集型程序的用户体验
1.4 进程和线程
进程是资源分配的基本单位,线程是调度的基本单位
线程共享进程数据,但也拥有自己的一部分“私有”数据:线程ID,一组寄存器(线程上下文数据使之可以被独立调度),栈(独立栈结构,线程是动态的概念),errno,信号屏蔽字,调度优先级。
进程的多个线程共享,同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:文件描述符表,每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数),当前工作目录,用户id和组id。
贰 Linux线程控制
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>void *threadrun(void *args)
{std::string name = (const char *)args;while (true){std::cout << "此为新线程: " << name << std::endl;sleep(1);}return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, threadrun, (void *)"thread-1");while (true){std::cout << "我是主线程" << std::endl;sleep(1);}return 0;
}
LWP -> Light weight process -> 轻量级进程
CPU在调度时是看的LWP~
任何一个线程崩溃,都会导致整个进程崩溃~
2.1 POSIX线程库
为什么要线程库呢~
Linux系统,不存在真正意义上的线程,线程是用轻量级进程模拟的。但OS中只有轻量级进程,模拟线程只是我们的说法。Linux只提供创建轻量级进程的系统调用~
但用户只认线程,所以OS给用户提供了(软件层)pthread库,把创建轻量级进程封装起来,给用户提供一批创建线程的接口。Linux的线程实现,是在用户层实现的,是用户级线程。pthread库为原生线程库。C++的多线程,在Linux下,本质是封装了pthread库。
2.2 Linux线程控制接口
2.2.1 pthread_create
NAMEpthread_create - create a new thread
SYNOPSIS#include <pthread.h>int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
RETURN VALUEOn success, pthread_create() returns 0; on error, it returns an error number, and the contents of *thread are undefined.
- 参数1:线程id,输出型参数
- 参数2:线程属性,栈大小等,一般设为默认nullptr
- 参数3:返回值void*,参数void*的函数指针,新线程执行入口
- 参数4:上函数的参数
2.2.2 pthread_self
NAMEpthread_self - obtain ID of the calling thread
SYNOPSIS#include <pthread.h>pthread_t pthread_self(void);Compile and link with -pthread.
RETURN VALUEThis function always succeeds, returning the calling thread's ID.
2.2.3 pthread_join
NAMEpthread_join - join with a terminated thread
SYNOPSIS#include <pthread.h>int pthread_join(pthread_t thread, void **retval);Compile and link with -pthread.
RETURN VALUEOn success, pthread_join() returns 0; on error, it returns an error number.
新线程创建好后一定要让主线程等待,不然会导致内存泄漏,类似僵尸进程。
小demo:
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>std::string FormatId(const pthread_t &tid) // 格式化输出, 是可重入函数
{char id[64];snprintf(id, sizeof(id), "0x%lx", tid);return id;
}void *routine(void *args)
{std::string name = static_cast<const char *>(args);pthread_t tid = pthread_self();int cnt = 5;while (cnt){std::cout << "新线程的名字是: " << name << std::endl;std::cout << "新线程的ID是: " << FormatId(tid) << std::endl;sleep(1);cnt--;}return (void *)123;
}int main()
{pthread_t tid;int n = pthread_create(&tid, nullptr, routine, (void *)"thread-1");(void)n;int cnt = 5;while (cnt){std::cout << "主线程的名字是: main thread" << std::endl;std::cout << "主线程的ID是: " << FormatId(pthread_self()) << std::endl;sleep(1);cnt--;}void *ret = nullptr;pthread_join(tid, &ret);std::cout << "ret is = " << (long long int)ret << std::endl;return 0;
}
运行时主线程和新线程都能调用FormatId函数,所以此函数是可重入函数。
- main函数结束,代表主线程结束,一般进程也结束了
- 新线程对应的入口函数,运行结束,代表当前线程运行结束
- 给线程传递的参数和返回值可以是任意类型
class Task
{
public:Task(int a, int b) : _a(a), _b(b) {};~Task() {};int Execute() { return _a + _b; }
private:int _a;int _b;
};
class Result
{
public:Result(int result) : _result(result) {};~Result() {};int GetResult() { return _result; }
private:int _result;
};void *routine(void *args)
{Task *t = static_cast<Task *>(args);sleep(1);Result *res = new Result(t->Execute());sleep(1);return res;
}int main()
{pthread_t tid;Task *t = new Task(2, 3);pthread_create(&tid, nullptr, routine, t);Result *ret = nullptr;pthread_join(tid, (void **)&ret);int n = ret->GetResult();std::cout << "新线程结束, 结果: " << n << std::endl;delete (t);delete (ret);return 0;
}
2.2.4 pthread_exit & pthread_cancel
NAMEpthread_exit - terminate calling thread
SYNOPSIS#include <pthread.h>void pthread_exit(void *retval);Compile and link with -pthread.
void *routine(void *args)
{Task *t = static_cast<Task *>(args);sleep(1);Result *res = new Result(t->Execute());sleep(1);//return res;pthread_exit(res);
}
线程不能通过exit()终止,可以用return或pthread_exit
NAMEpthread_cancel - send a cancellation request to a thread
SYNOPSIS#include <pthread.h>int pthread_cancel(pthread_t thread);Compile and link with -pthread.
主线程可以cancel新线程~线程如果被取消,结果是-1 【PTHREAD_CANAELED】
2.2.5 分离线程
如果主线程不想再关心新线程,而是当新线程结束的时候,让他自己释放,可以设置新线程为分离状态~
线程默认是需要被等待的,joinable。如果不想让主线程等待新线程,想让新线程结束之后,自己退出,要设置为分离状态(!joinable or detach) 。线程分离,可以主线程分离新线程,也可以新线程把自己分离。分离的线程,依旧在进程的地址空间中,进程的所有资源,被分离的线程,依旧可以访问,可以操作。主线程不等待新线程。如果线程被设置为分离状态,不需要进行join,join会失败~
NAMEpthread_detach - detach a thread
SYNOPSIS#include <pthread.h>int pthread_detach(pthread_t thread);Compile and link with -pthread.
2.2.6 多线程创建方法
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <vector>const int num = 10;void *routine(void *args)
{std::string name = static_cast<const char *>(args);delete (char *)args; // 回收掉拷贝的空间int cnt = 5;while (cnt--){std::cout << "新线程名字: " << name << std::endl;sleep(1);}// sleep(1);return nullptr;
}int main()
{// 创建10个线程std::vector<pthread_t> tids;for (int i = 0; i < num; i++){pthread_t tid;// char id[64]; 不行,会导致数据传入新线程的地址相同,数据不一致,安全性降低char *id = new char[64]; // 每次开一块空间,id传到新线程时是拷贝,所以空间释放也没事snprintf(id, 64, "thread-%d", i); // 线程名格式化打印到缓冲区int n = pthread_create(&tid, nullptr, routine, id); // 主线程if (n == 0)tids.push_back(tid);elsecontinue;// sleep(1);}// 等待线程, 一个个依次回收for (int i = 0; i < num; i++){int n = pthread_join(tids[i], nullptr);if (n == 0)std::cout << "等待新线程成功~" << std::endl;}return 0;
}
监控:
while :; do ps -aL | head -1 && ps -aL | grep thread;sleep 1;done
叁 线程ID及进程地址空间布局
线程是在库中维护的。库中有多个线程的话,也需要在库中(用户层)管理起来。struct tcb中就包含了线程相关属性:状态,id,独立栈结构,栈大小等。而优先级,时间片,上下文等在内核区的LWP(PCB)中。
每创建一个线程,在mmap区就会增加一块线程管理块。pthread_self()的返回值就是这个块的虚拟起始地址。
在struct pthread 中有一个joinable变量用来表示是否分离。需要线程分离时,joinable设为0,函数执行完中断销毁管理块。
在struct pthread 中有一个void * ret用来保存函数的返回值。当线程函数运行完后,返回值在ret中,虽然执行完了,但这个线程管理块不会销毁,需要等待主线程join。所以等待就是去线程起始tid地址,块中获取返回值。
void *ret = nullptr;
pthread_join(tid, &ret);
以下是创建一个线程线程的两个步骤:
线程局部存储:想用全局变量,又不想让这个全局变量被其他线程看到时可以用。
__thread int count = 1;
线程局部存储,只能存储内置类型和部分指针,不能存储类和函数。两个进程用这个变量时,此变量虚拟地址不同。
肆 线程封装
#ifndef _THREAD_H_
#define _THREAD_H_#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <vector>
#include <functional>
#include <stdlib.h>namespace ThreadModule
{static uint32_t number = 0;class Thread{using func_t = std::function<void()>;public:Thread(func_t func): _tid(0), _isdetach(false), _isrunning(false), _res(nullptr), _func(func){_name = "thread-" + std::to_string(number++);}~Thread(){}void Detach(){if (_isdetach)return;if (_isrunning) // 启动之后手动设置分离pthread_detach(_tid);EnableDetach(); // 启动之前分离, 改标志位}bool Start(){if (_isrunning)return false;int n = pthread_create(&_tid, nullptr, Routine, this);if (n != 0){std::cerr << "create thread error" << strerror(n) << std::endl;return false;}else{std::cout << _name << " create success" << std::endl;return true;}}bool Stop(){if (_isrunning){int n = pthread_cancel(_tid);if (n != 0){std::cerr << "cancel thread error" << strerror(n) << std::endl;return false;}}else{_isrunning = false;std::cout << _name << "stop success" << std::endl;return true;}return false;}void Join(){if (_isdetach){std::cout << "你的线程已经是分离的了, 不能join了" << std::endl;return;}int n = pthread_join(_tid, &_res);if (n != 0){std::cerr << "join thread error" << strerror(n) << std::endl;}else{std::cout << "join thread success" << std::endl;}}private:void EnableDetach(){std::cout << "线程被分离了" << std::endl;_isdetach = true;}void EnableRunning(){_isrunning = true;}// 不带static会变成类内函数, 默认隐藏参数this, 创建导致格式不兼容static void *Routine(void *args) // 传入当前对象地址{Thread *self = static_cast<Thread *>(args);self->EnableRunning(); // 启动if (self->_isdetach)self->Detach();self->_func(); // 回调处理return nullptr;}private:pthread_t _tid;std::string _name;bool _isdetach;bool _isrunning;void *_res;func_t _func;};} // namespace ThreadModule#endif