Linux - 线程

线程的概念

线程就是一个程序里面的执行路线,叫线程。一个炒菜操作,我可以同时让多人进行烧锅倒油、切菜、烧水、洗盘子,这就是一个程序的多线程操作。

一个进程至少有一个线程,如果一个线程都没有那就不能叫进程了,根本没有动。

线程在内部运行,本质是在进程的地址空间里运行

要真正理解线程,我们得从页表开始

分页式存储管理

页表来源

如果没有虚拟地址一个用户的物理内存必须是连续的,这样就会导致大量的内存碎片

因此我们有了页表,来进行虚拟连续地址到多离散的物理地址的映射

页框

把物理内存按照一个固定的长度的页框进行分割,有时叫做物理页。每个页框包含一个物理页(page)。一个页的大小等于页框的大小。大多数 32位体系结构支持4KB的页,而64位体系结构一般会支持8KB的页。区分一页和一个页框是很重要的:页是虚拟地址的存储块,页框是物理地址的存储块

通过页表将页和页框进行联系起来

物理页框管理

页表可以存储页框的状态(例如是不是脏页,是否上锁)、还有引用计数(这一页被引用多少次,归为-1说明没有引用过这一页,就可以重新分配它)

如果使用struct page结构体管理这些信息,假定大小是40字节,在32位下,就有4GB/4KB=1M,因此需要40MB来存储,起始也还行。

页表

如果是直接使用虚拟地址映射物理地址,那么就需要存储物理地址一个四字节(32位下)

我们知道物理地址一共有4GB,如果直接存每个物理地址那根本存不下,因此使用了按页框映射并使用偏移量查找的方式。此时就有了4GB/4KB=1MB(1048576)个表项,如果一个地址是4字节,就需要4MB的内存去存储,就占了4MB/4KB=1024个表项,这是占的很多的。更何况如果是64位下。

因此我们有了多级页表

多级页表

一共要存储1024*1024个表项,我们可以变成第一层存储1024个,第二层再存储1024个。这样看起来很原来的是没有差别的,但是一个程序不能一下使用全部的空间的,只需要几个页表就够了,而这样我们就可以先不用急着把第二层所有的开辟出来。例如我们运行一个12MB的程序,那么将其全部加载到内存也只需要3个页表项,我们也就只需要创建三个页表项。

二级页表的地址转换

对于一级页表我们只需要模4KB即可,就能找到一个页表项。但是现在是双层,第一层只有1024个,那么每个第一层就可以存储1024(第二层的)*4KB的内容,因此我们要模4MB,然后就可以找到第一层,然后再获取中间1024的10位字节,就能得到二级页表的下标,最后就能求得物理地址了。这个流程就是MMU的工作内容

多级页表也是一把双刃剑,增加了地址的计算次数,特别访问地址这个操作是极其频繁的,这会把一点点的性能减小放大的很大。因此有了TLB快表来缓解这个问题。TLB是CPU高速缓存的地址缓存,所有进程共用一个。

因此MMU的工作流程就是先查TLB,然后未命中再去查,最后记录到TLB

缺页异常

如果TLB和页表都没有找到物理页或者存在但没有权限,就会抛出缺页异常。此时需要交给操作系统 PageFaultHandler 来处理

缺页异常有三种情况:

Hard Page Fault(硬件缺页错误) ,这是物理内存中没有对应的物理页,需要CPU打开磁盘设备将数据读取到物理内存中
Soft Page Fault(软件缺页错误),这是物理内存中是存在物理页的,只不过是其他进程调入的,但是它刚好不知道,此时就直接建立映射就行了,这个一般发生在多进程共享内存区域

Invalid Page Fault(无效缺页错误),比如进程访问的地址越界访问,或者空指针解引用,内核就会直接报错,发出信号,如果信号是默认的,直接终止进程。

我直接用解引用来说明一下完整流程,首先执行页表访问,如果TLB没有,就会访问页表,如果访问页表发现没有地址或页表和TLB有且发现权限不足,那么就会发送缺页异常,此时进入内核态,然后处理相关的异常,如果发现是非法操作,那么就会在当前进程的pending表对应信号上标记,等异常处理完后,就会处理pending表,发现有未决信号并处理。

理解new、malloc、写时拷贝?

new malloc内存,系统不会立马分配物理地址,而是只分配虚拟地址,当真正尝试访问物理地址时,就会因为物理地址不存在触发缺页异常,然后分配物理地址建立映射,这时就能正常运行了;

写时拷贝是将物理内存设置为只读的,当有尝试读取操作的时候,就会触发缺页异常,此时检查发现是只读的,就会分配一个新的物理地址并建立映射。

线程的优点

创建一个新线程比创建一个新进程的代价要小很多,不需要创建新的页表,不需要新的地址空间,不需要复制文件描述符表等等

与进程相比,线程的切换操作也小很多,线程切换进程地址空间是不需要切换的,然后就是缓存机制例如TLB也会是性能差异的点,线程切换不需要刷新TLB。大家应该知道缓存命中率吧,高概率的缓存命中率对性能提升是很大的,而地址缓存命中率也是一种。

线程占用的资源要比进程少

充分利用多处理器的可并行数量

在等待慢速I/0操作结束的同时,程序可执行其他的计算任务

计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现

I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作

线程的缺点

可能的性能损失:如果线程数量比CPU核数多得多,这会导致线程切换更频繁,多了很多无用的开销

健壮性降低:需要更加深入的考虑,在一个多线程程序中,因为内存的分配,访问的模式稍微的差别,就会导致不良影响。而且不易排查

缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。

编程变难:这个应该很容易想得到

线程异常

单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃

线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出

这也是线程的一个缺点,一个崩溃,进程就会奔溃,所有线程都会退出

进程VS线程

进程之间独立,线程之间共享进程地址空间

进程是资源分配的单位,线程是调度的单位

线程私有数据

线程ID

一组寄存器和上下文(因为一个线程工作在一个CPU里面,切换线程需要保存)

栈(就是函数栈帧)

线程局部存储

errno(这个在线程局部存储里面)

信号屏蔽字

调度优先级

线程共享数据

文件描述符表

每种信号的处理方式(SIG_IGN、SIG_DFL或者自定义的信号处理函数)

当前工作目录

用户id和组id

Linux线程控制

cpp 复制代码
#include<pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *
(*start_routine)(void*), void *arg);

thread作为返回型参数返回线程Id

attr设置线程属性,为空默认属性

start_routine 线程启动时调用的函数

arg 传给线程启动函数的参数

cpp 复制代码
#include <pthread.h>
// 获取线程ID
pthread_t pthread_self(void);

pthread_t的线程Id也可以通过pthread_self来获取 。这个Id不是系统级的id(内核不认识),pthread库底层通过clone系统调用创建线程

cpp 复制代码
void pthread_exit(void *value_ptr);

可以调用pthread_exit终止线程,当然直接函数返回也可以

cpp 复制代码
int pthread_cancel(pthread_t thread);

这个函数可以让某个线程终止,只要传入线程id即可

cpp 复制代码
int pthread_join(pthread_t thread, void **value_ptr);

类似于子进程,线程退出时也有空间没有释放,需要调用此函数等待

1.如果thread线程通过return返回,value_ptr所指向的单元里存放的是thread线程函数的返回值。

2.如果thread线程被别的线程调用pthread_cancel异常终掉,value_ptr所指向的单元里存放的是常

数PTHREAD_CANCELED。

3.如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传pthread_exit的参数。

4.如果对thread线程的终止状态不感兴趣,可以传NULL给value_ptr参数。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
void *thread1( void *arg )
{
printf("thread 1 returning ... \n");
int *p = (int*)malloc(sizeof(int));
*p = 1;
return (void*)p;
}
void *thread2( void *arg )
{
printf("thread 2 exiting ...\n");
int *p = (int*)malloc(sizeof(int));
*p = 2;
pthread_exit((void*)p);
} v
oid *thread3( void *arg )
{
while ( 1 ){ //
printf("thread 3 is running ...\n");
sleep(1);
} r
eturn NULL;
} i
nt main( void )
{
pthread_t tid;
void *ret;
// thread 1 return
pthread_create(&tid, NULL, thread1, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
free(ret);
// thread 2 exit
pthread_create(&tid, NULL, thread2, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
free(ret);
// thread 3 cancel by other
pthread_create(&tid, NULL, thread3, NULL);
sleep(3);
pthread_cancel(tid);
pthread_join(tid, &ret);
if ( ret == PTHREAD_CANCELED )
printf("thread return, thread id %X, return code:PTHREAD_CANCELED\n",
tid);
else
printf("thread return, thread id %X, return code:NULL\n", tid);
}

分离线程

默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。

如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。

cpp 复制代码
int pthread_detach(pthread_t thread);

pthread_t类型

本质是进程地址空间上的一个地址

TLS线程局部存储

什么是TLS

每个线程不仅有自己的栈空间,还有自己的线程局部存储,它是线程所独享的,可以被线程单独使用,PCB里面存储了fs寄存器来存储线程局部存储的偏移量。这样当访问某个线程局部内存的时候,只需要fs+地址偏移量就可以访问到内存,因此和访问普通变量地址的效率是几乎一样的。

局部存储声明

这里直接建议使用C++11的thread_local关键字,就能直接声明TLS形式的局部存储变量

cpp 复制代码
void func(threadCache&x) {
	while (true) {
		static thread_local int a = 0;
		thread_local int b = 0;
		x.get()++;
		cout << this_thread::get_id() << ":" << x.get()<<" a:"<<++a<<" b: "<<++b << endl;
		this_thread::sleep_for(chrono::milliseconds(1500));
	}
}

这里的a 和 b在局部存储里面是等效的声明方式,在全局如果是static只能本Cpp文件看得到,如果没有声明且加上extern 可以全局看得到,或者使用inline

cpp 复制代码
class A{
public:
private:
    inline thread_local static int a=1;
    static inline  int b =2;
};

在类内使用TLS,必须加上static,因为是存储在每个线程的TLS里的,不能存储在堆区和栈区(如果不加static就成其普通成员变量了)。

局部存储优点

优点很明显,声明的局部存储可以很快的访问,没有线程安全问题,在一些情况下可以极大优化性能。例如在内存池里,可以让每个线程都持有自己的内存池,减少线程之间的冲突

局部存储的缺陷

线程局部存储的大小一般不超过128KB,太大会创建线程失败。

另外如果线程局部存储的内存比较大,在线程创建的时候要为其分配独立的TLS内存块,线程销毁时,也需要释放对应内存,对于频繁创建释放内存的情况可能会成为性能瓶颈

使用thread_local int* p =new int出来的只有p在TLS里面,new出来的内存还是在堆内

注意事项

线程池使用TLS需要注意,线程并没有释放,因此TLS没有释放,如果给线程分配新任务需要注意TLS旧数据不会导致问题

使用时减少TLS的体积

避免高频线程创建

手动管理TLS堆内存,或者使用智能指针

使用thread_local跨平台封装

线程带来的问题

多个线程操作同一个区域的共享资源时,会发生资源抢夺,可能会对数据结构、数据产生不可逆的改变,导致最终可能程序崩溃或者结果不正确。因此光会创建线程是没用的,需要知道怎么进行线程的同步与互斥操作。这也是下一讲的内容

相关推荐
diediedei2 小时前
C++中的适配器模式变体
开发语言·c++·算法
EverydayJoy^v^2 小时前
RH134学习进程——八.管理存储堆栈
linux·运维·服务器
天赐学c语言2 小时前
1.25 - 零钱兑换 && 理解右值以及move的作用
c++·算法·leecode
北冥湖畔的燕雀2 小时前
C++智能指针:告别内存泄漏的利器
c++·算法
CSDN_RTKLIB2 小时前
【编码实战】源字符集设置
c++
安全二次方security²2 小时前
CUDA C++编程指南(7.5&6)——C++语言扩展之内存栅栏函数和同步函数
c++·人工智能·nvidia·cuda·内存栅栏函数·同步函数·syncthreads
爱编码的傅同学2 小时前
【线程同步】信号量与环形队列的生产消费模型
linux·windows·ubuntu·centos
D_evil__2 小时前
【Effective Modern C++】第三章 转向现代C++:10. 优先选用限域枚举,而非不限域枚举
c++
是娇娇公主~2 小时前
算法——【最长回文子串】
c++·算法