- 站在内核角度来理解进程:承担分配系统资源的基本实体,叫做进程
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列"
- 一切进程至少都有一个执行线程
- 线程在进程内部运行,本质是在进程地址空间内运行
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
需要明确的是,一个进程的创建实际上伴随着其进程控制块(task_struct)、进程地址空间(mm_struct)以及页表的创建,虚拟地址和物理地址就是通过页表建立映射的,如下图:
每个进程都有自己独立的进程地址空间和独立的页表,也就意味着所有进程在运行时本身就具有独立性,但如果我们在创建“进程”时,只创建task_struct,并要求创建出来的task_struct和父task_struct共享进程地址空间和页表,那么创建的结果就是下面这样的:
此时我们创建的实际上就是四个线程:
线程与进程的包含问题:
- 下面用蓝色方框框起来的内容,我们将这个整体叫做进程,进程包含线程
- 因此,所谓的进程并不是通过task_struct来衡量的,除了task_struct之外,一个进程还要有进程地址空间、文件、信号等等,合起来称之为一个进程
- 线程是进程的一个执行分支,是在进程内部运行的一个执行流,是操作系统进行运算调度的最小单位
- 在linux里我们也把线程成为轻量级进程(LWP,LightWeightProcess),因为linux里其实没有真正的线程,线程是通过进程模拟出来的(在内核里都是一个个的task_struct)
- 没学线程前我们说进程是操作系统最小的调度单位,因为那时我们写的代码都是单线程的,一个进程只有一个执行流,所以那么说也没错,准确一点就是线程是操作系统调度的最小单位
线程的执行流分为两种:单执行流与多执行流
【重要】通过上面两张图得出的一些结论,同时补充一些概念:
- 站在CPU的角度,进程与线程没有任何区别,它看到的都是task_struct,这也是为什么线程在linux里也叫轻量级进程,也说明linux下没有真正意义上的线程。简单来说,CPU对线程0感知
- 线程之间是共用一个地址空间的,这说明比起进程之间的切换,线程的切换更加轻量级。因为进程切换可能要保存页表、地址空间的数据甚至缓存的数据等等,而线程之间切换时这些都不用动,自然切换的代价也比较小
- 进程:线程=1:n,说明系统内有大量的线程,所以操作系统必定要把线程管理起来,说明如果一个系统支持真正的线程,比如windows,那必然是有一个结构(TCB)来描述这个线程的属性,并且将其组织起来,但是往往比较复杂,linux下虽然没有真正的线程,但是优点就是简单
- linux下没有真正意义的线程,这就说明OS不可能在系统层面提供操作线程的接口,而是一些封装好给用户的轻量级接口
- CPU调度的都是线程,即线程是CPU调度的最小单位
- 站在系统的角度,进程是承担系统资源的基本单位。因为第二个线程用的资源都是第一个线程申请好的
- 线程在进程内部运行,这句话的意思是线程在进程地址空间内运行
- 通过页表可以看到物理内存,也即真正的资源,同时说明只要划分页表我们就可以让线程看到进程的部分资源
- 关于页表,页表不仅仅记录了虚拟地址与物理地址的映射,还有一些别的属性,如权限,是否命中等等,这说明页表的大小不是一个字节就能记录的。一般32位的机器物理内存是4G,说明有232这么多个地址要映射,即页表要记录232个映射关系,表示每一个映射关系需要的字节都大于1,说明页表整体大小大于4G,放不进内存,此时就引出了二级页表。负责这块的硬件就是MMU,具体的了解可以查询二级页表的相关资料
- 线程数越多越好吗?并不是,建议与计算机的核数相当。线程过多会导致大部分时间花在调度上,而没有花在线程的执行上,有点买椟还珠的意思
- 没有线程替换,线程替换就是整个进程被替换
- CPU不需要线程的概念,linux下线程这个概念是给用户的,因为用户需要多线程编程
- 用户层通过TCB(线程控制块)来知道线程的id、状态、优先级和其他属性,用来进行用户级的线程管理。TCB不由内核维护,而是由用户空间维护
线程的优点:
- 创建一个新的线程的代价比创建一个进程小得多。创建一个线程虽然也需要创建数据结构,但是并不需要重新开辟资源,只需要将进程的部分资源分配给线程。创建一个进程不仅需要创建大量数据结构,还需要重新创建资源
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作少。线程只是进程的部分资源,切换的资源少
- 线程占用的资源比进程少
- 能充分利用多处理器的可并行数量
- 在等待慢速的I/O操作结束的同时,程序可以执行其它的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠,线程可以同时等待不同的I/O操作。I/O操作是与外设交互数据,会很慢
线程的缺点:
- 性能缺失
- 一个处理器只能处理一个线程,如果线程数比可用处理器数多,会有较大的性能损失,会增加额外的同步和调度开销,而资源不变
- 鲁棒性降低
- 编写多线程时,可能因为共享了不该共享的变量,一个线程修改了该变量会影响另外一个线程。多线程之间变量时同一个变量,多进程之间变量不是同一个变量,写时拷贝
- 缺乏访问的控制
- 进程时访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响
- 编程难度高
线程的异常:
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
线程的用途:
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
- 进程是资源分配的基本单位,线程是调度的基本单位
- 进程:线程 = 1:n
- Linux中没有真正意义上的线程,线程是用进程模拟的
- 线程又称为轻量级进程
- 线程共享进程数据,但也拥有自己的一部分数据
- 进程的健壮性比线程好
- 多线程比进程消耗资源少,切换快,运行效率高
多进程应用场景:shell、守护进程、分布式服务
多线应用场景:
- 不同任务间需要大量共享数据或频繁通信时
- 提供非均质的服务(有优先级任务处理)事件响应有优先级
- 单任务并行计算,在非CPU Bound的场景下提高响应速度,降低时延
- 与人有IO交互的应用,良好的用户体验(键盘鼠标的输入,立刻响应)
在Linux中由于没有真正的线程,目前的线程都是用原生线程库(Nagtive POSIX Thread Library)来实现。在这种实现下,线程又被称作轻量级进程,因为线程仍然使用进程描述符task_struct,但是只是执行进程的部分内容
没有线程之前,一个进程对应内核的一个进程描述符,对应进程的ID。引入线程之后,一个进程对应了一个或者多个线程,每一个线程作为CPU调度的基本单位,在内核态也有自己的ID
线程组,多线程的进程,又被称为线程组。每一个线程在内核中都存在一个进程描述符(task_struct),因为Linux下,用进程来模拟线程。进程结构体中的pid,表明上看是进程ID,其实不是,它实际对应线程ID,进程描述符中的tgid,对应用户层面的进程ID
我们来看看内核源码是什么样的:
- 总结:进程有自己的ID在源码中是pid,线程也有自己的ID,在源码中是tgid
进程ID有什么用呢?可以表示线程属于哪个进程的。就可以知道进程有多少线程
在创建线程使用的函数pthread_create的第一个参数返回的也是线程的id但是和这里的线程id,不过,这里的线程id是用来标识线程的,后面有介绍创建线程函数返回的id
查看线程id的命令:
ps -aL
- PID显示的是进程ID
- LWP显示的是线程ID
我们发现进程mythread有两个线程,一个线程的id是7854,一个线程的ID是7855。整个进程的ID是7854
但是有一个线程的ID和进程的ID相同,这不是巧合。线程组(进程)里的第一个线程,在用户态被称为主线程,在内核中被称为group leader。线程中创建的第一个线程,会将该线程的ID设置成和线程组的ID相同。所以线程组内存在一个线程ID和进程ID相同,这个线程为线程组的主线程
- 至于线程组的其它线程ID则由内核负责分配。线程组的ID总和主线程ID一致
- 一个进程至少有一个线程。如果没有创建线程,该进程就是单线程的单进程
- 注意:线程和进程不一样,进程有父子进程的概念,但是线程没有,所有进程都是对等的关系
创建线程库函数是:pthread_create
这个函数明确说明了需要链接库名-pthread,我们需要思考一些问题:
为什么连接线程库要指明库名?标准库不用指明库名?
- 因为标准库是语言自带的,第三方库不是语言自带的,可能是系统或者是用户自己安装的,线程库是Linux系统安装的,不是语言提供的,对于gcc编译器来说是第三方库。gcc默认连接库是标准库(语言提供的)。编译器命令行参数中没有第三方库的名字。所以给编译器指明库名
- 强调:找到库所在路径和使用该路径下的库文件,是两码事。找到路径找不到库,还需要指明库名。标准库中因为编译器命令行中有该库名
让我们来看看使用的例子:
mythread.cpp代码:
#include
#include
#include
using namespace std;
void* thread_run(void* arg)
{
while(1)
{
cout<<"i am "<<(char*)arg<<endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
int ret=pthread_create(&tid,NULL,thread_run,(void*)"thread 1");
if(ret!=0)
{
return -1;
}
while(1)
{
cout<<"i am main thread"<<endl;
sleep(2);
}
return 0;
}
makefile代码:
mythread:mythread.cpp
g++ $^ -o $@ -lpthread
.PHONY:clean
clean:
rm -f mythread
例子结果:
- 注意:主线程退出,整个进程就退出了
- 要某个线程终止而不让进程终止,有三种方法:
- 从线程函数return,这种情况对主线程不适用,因为主线程退出,整个进程就退出了
- 线程可以调用pthread_exit终止
- 一个线程可以调用pthread_cancel终止同一进程里的线程
新线程也可以用pthread_cancel终止主线程:
pthread_exit函数:
注意:使用return和pthread_exit返回的指针所指向的内存单元必须是全局或者是malloc分配的,不能是在线程函数栈上分配的,因为线程退出时,函数栈帧被释放了
pthread_cancel函数:
注意:不能使用exit(),exit的作用是不论在哪里调用,终止进程
线程为什么需要等待?
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内
- 创建的新线程不会复用刚才退出线程的地址空间
- 默认以阻塞方式等待
- 线程退出和进程退出一样,有三种状态:
代码正常运行,结果正确,正常退出
代码正常运行,结果不正确,不正常退出
代码出现异常,异常退出
- 前两种情况以退出码来表述退出情况,后面一种以退出信号来表示
但是线程等待函数的第2个参数返回的是执行函数的返回值,也就是退出码,没有表示线程异常退出的情况,这是为什么的?
- 因为某个线程如果运行异常终止,整个进程都会终止。进程异常终止,就属于进程的等待处理的范畴了,不属于线程范畴。比如:一个线程函数有除0操作,硬件MMU发现异常,操作系统收到异常,向该进程发出信号,终止进程。信号处理的单位是进程
总的来说就是,等待线程只关心正常运行的退出情况,获取退出码。不关心异常退出情况,异常退出情况上升至进程处理范畴
那么这里我们怎么获取退出码呢?
调用pthread_join函数的线程默认以阻塞方式等待线程id为thread参数的线程终止,线程以不同的方式终止,得到的终止状态不同
- 如果线程通过return终止,pthread_join函数的第二个参数retval直接指向return后面的返回值
- 如果线程通过pthread_exit终止,pthread_join函数的第二个参数retval直接指向pthread_exit参数‘
- 如果线程通过被其它线程调用pthread_cancel终止,pthread_join函数的第二个参数retval直接存放的是一个常数宏PTHREAD CANCELED,值是-1。
#define PTHREAD CANCELED (void *)-1
- 如果对不关心返回值,可以将ret_val设为NULL
三种情况的返回值如下图:
return:
pthread_exit:
pthread_cancel:
- 上面讨论的线程ID(LWP)属于进程调度范畴。因为线程是轻量级进程,是操作系统调度的基本单位,所以会需要一个ID来标识给线程
- 这里讨论的线程ID,是创建线程函数pthread_create的第一个参数。该内存是线程第三方库为线程在内存中开辟的一块空间。该线程ID返回该空间的起始地址。这个进程ID数据线程库的范畴,线程库的后序操作,就是根据该线程ID来操作的
为什么线程ID返回的是起始地址?
由于Linux没有真正意义上的线程,线程管理需要线程库来做,线程库管理线程也是要先描述再组织,描述如下图,组织成一个数组,再返回数组的起始地址
可以通过函数查询当前线程ID:
传参问题的探讨与验证:
(void*)
类型,然后将参数的地址传入,而在工作线程中使用是只需将(void*)
转换为(int*)
即可,如下代码:#include
#include
#include
using namespace std;
void* MyThreadStrat(void* arg)
{
int* i=(int*)arg;//(void*)变成了(int*)
while(1)
{
cout<<"MyThreadStrat:"<<*i<<endl;
sleep(1);
}
return NULL;
}
int main()
{
pthread_t tid;
int i=1;
int ret=pthread_create(&tid,NULL,MyThreadStrat,(void*)&i);
if(ret!=0)
{
cout<<"线程创建失败!"<<endl;
return 0;
}
while(1)
{
sleep(1);
cout<<"i am main thread"<<endl;
}
return 0;
}
从上面的结果可以看出,虽然参数可以正常传入,但实际是存在一定的错误的,因为局部变量 i 传入的时候生命周期未结束,而在传递给工作线程的时候生命周期结束了,那么这块局部变量开辟的区域就会自动释放,而此时工作线程还在访问这块地址,就会出现非法访问
让我们将代码改成循环的来看看:
#include
#include
#include
using namespace std;
void* MyThreadStrat(void* arg)
{
int* i=(int*)arg;
while(1)
{
cout<<"MyThreadStrat:"<<*i<<endl;
sleep(1);
}
return NULL;
}
int main()
{
pthread_t tid;
int i=0;
for( i=0;i<4;i++)
{
int ret=pthread_create(&tid,NULL,MyThreadStrat,(void*)&i);
if(ret!=0)
{
cout<<"线程创建失败!"<<endl;
return 0;
}
}
while(1)
{
sleep(1);
cout<<"i am main thread"<<endl;
}
return 0;
}
原因:因为for循环4次最终开辟4个工作线程,开辟线程传递进去的是 i 的地址,而 i 中的值从0加到4,而 i 到5退出,此时 i 已经被加为4,最终 i 的地址中存的值为 4,使用最终会一直输出4
那么上面的传参问题如何解决呢?接下来我们来看看:
解决办法:动态内存开辟
方法一:传递this指针
class MyThread { public: MyThread(){} ~MyThread(){} int Start() { int ret = pthread_create(&tid_, NULL, MyThreadStart, (void*)this); if(ret < 0) { return -1; } return 0; } static void* MyThreadStart(void* arg) { MyThread* mt = (MyThread*)arg; printf("%p\n", mt->tid_); } private: pthread_t tid_; }; int main() { return 0; }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
方法二:传递结构体指针
#include
#include #include using namespace std; struct ThreadId { int thread_id; }; void* MyThreadStrat(void* arg) { struct ThreadId* tid=(struct ThreadId*)arg; while(1) { cout<<"MyThreadStrat:"<<tid->thread_id<<endl; sleep(1); } delete tid; } int main() { pthread_t tid; int i=0; for( i=0;i<4;i++) { struct ThreadId* id=new ThreadId(); id->thread_id=i; int ret=pthread_create(&tid,NULL,MyThreadStrat,(void*)id); if(ret!=0) { cout<<"线程创建失败!"<<endl; return 0; } } while(1) { sleep(1); cout<<"i am main thread"<<endl; } return 0; }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
通过开辟动态内存方法,解决传参问题后,我们看到已经可以正常创建工作进程了!
一点小思考:
- 思考一:非法访问不属于自己的内存程序是否会崩溃?有可能!
- 当这块内存没有被其他程序使用的时候,非法访问不属于自己的内存,程序不会崩溃,若这块内存已经被其他程序使用了,则非法访问这块内存程序会崩溃
- 思考二:线程的顺序执行是怎么样的?抢占式执行!
- 一般情况下,主线程总是优先于工作线程,除非像上面一样用了sleep函数故意区分顺序
- 原因:在一个主线程中开辟一个新的线程,则新开辟的线程称为工作线程,工作线程始终优先级与主线程相同,但主线程先启动占用了CPU资源,因此主线程总是优先于工作线程
- 默认情况下,新创建的线程是joinable的,需要创建新线程的线程等到新线程,新线程退出后需要对其进行pthread_join操作,否则无法释放资源,造成内存泄漏
- 如果不关心返回值,我们可以告诉系统,将线程分离,当线程退出后,自动释放线程资源
- 注意:可以是线程组内其它线程对目标线程分离,也可以是线程分离自己
创建新线程的线程,不关心新线程的返回值,可以使用线程分离:
但是线程虽然分离了,当分离的线程因为异常终止,依然会导致进程终止:
相关概念了解:
临界资源
:多线程执行流共享的资源叫临界资源,并不一定所有的共享资源是临界资源,是多个线程访问的资源才是临界资源临界区
:每个线程内部,访问临界资源的代码,叫做临界区互斥
:任何时刻,互斥保证有且只有一个执行流进入临界区,访问灵界资源,通常对临界资源起保护作用。一个能进,一个不能进原子性:
不会被人后调度机制打断的操作,该操作只有两态,要么完成,要么未完成,没有中间态。可以理解成只有一个汇编代码。比如修改一个变量,一个执行流进来,要修改时,又有一个执行流进来,该值还是原来的值,会导致变量只修改一次,其实修改了两次
线程安全:多个线程并发同一段代码时,不会出现不同的结果。多个执行流,访问临界资源,不会导致程序产生二义性
- 执行流:理解为线程
- 访问:指的是对临界资源进行操作
- 临界资源:指的是多个线程都可以访问到的资源
- eg:全局变量,某个结构体(不能是定义在某个线程入口函数内),某个类的实例化指针
- 临界区:代码操作临界资源的代码区域称之为临界区
- 二义性:结果会有多个
线程不安全:
- 假设有一个CPU,两个线程,线程A和线程B,线程A和线程B都要对全局变量 i 进行++操作
- 假设线程A先运行,但是线程A将 i 的值读到寄存器之后,就被线程切换为了B
- 假设线程B运行,正常继续++操作并完成。i 的值在内存当中被修改增加了1了
- B时间片到了,线程A被切换回来,怎么计算?i 的值是多少?
- 总结:两个线程都执行了++操作,但内存中的结果值只有一个,这就是线程不安全带来的结果
下面我们来剖析一下线程安全与不完全进行++操作的过程:
线程安全时的++操作:
- 正常情况,假设我们定义一个变量 i 这个变量 i 一定是保存在内存的栈当中的,我们要对这个变量 i 进行计算的时候,是CPU(两大核心功能:算术运算和逻辑运算)来计算的,假设要对变量 i = 10 进行 +1 操作,首先要将内存栈中的 i 的值为 10 告知给寄存器,此时,寄存器中就有一个值 10,让后让CPU对寄存器中的这个 10 进行 +1 操作,CPU +1 操作完毕后,将结果 11 回写到寄存器当中,此时寄存器中的值被改为 11,然后将寄存器中的值回写到内存当中,此时 i 的值为 11
线程不安全时的++操作:
- 假设有两个线程,线程A和线程B,线程A和线程B都想对全局变量 i 进行++
- 假设全局变量 i 的值为 10,线程A从内存中把全局变量 i = 10 读到寄存器当中,此时,线程A的时间片到了,线程A被切换出来了,线程A的上下文信息中保存的是寄存器中的i = 10,程序计数器中保存的是下一条即将要执行的 ++ 指令,若此时线程B获取了CPU资源,也想对全局变量 i 进行 ++ 操作,因为此时线程A并未将运算结果返回到内存当中,所以线程B从内存当中读到的全局变量 i 的值还是10,然后将 i 的值读到寄存器中,然后再在CPU中进行 ++ 操作,然后将 ++ 后的结果 11,回写到寄存器,寄存器再回写到内存,此时内存当中 i 的值已经被线程B机型 ++ 后改为了 11,然后线程B将CPU资源让出来,此时线程A再切换回来的时候,它要执行的下一条指令是程序计数器中保存的对 i 进行 ++ 操作 ,而线程A此时 ++ 的 i 的值是从上下文信息中获取的,上下文信息中此时的 i = 10 ,此时线程A在CPU中完成对 i 的 ++ 操作,然后将结果 11 回写给寄存器,然后由寄存器再回写给内存,此时内存中的 i 被线程B改为了 11,虽然 ,线程A和线程B都对全局变量 i 进行了 ++ ,按理说最终全局变量 i 的值应该为12,而此时全局变量 i 的值却为11
- 总结:线程A对全局变量 i 加了一次,线程B也对全局变量 i 加了一次,而此时,全局变量的值为 11 而不是 12,由此就产生了多个线程同时操作临界资源的时候有可能产生二义性问题(线程不安全现象)
这么说太复杂了,我们来画个图理解一下吧:
我们来举个例子看看:线程不安全的情况
//这里举例黄牛买票的经典案例
#include
#include
#include
using namespace std;
int ticket=1000;//票数量
void* get_ticket(void* arg)
{
while(1)
{
if(ticket>0)
{
cout<<"i am "<<pthread_self()<<" get a ticket,no:"<<ticket<<endl;
ticket--;
}
else
{
break;
}
}
return NULL;
}
int main()
{
pthread_t tid[4];
for(int i=0;i<4;i++)
{
int ret=pthread_create(&tid[i],NULL,get_ticket,NULL);
if(ret!=0)
{
cout<<"线程创建失败!"<<endl;
}
}
for(int i=0;i<4;i++)
{
pthread_join(tid[i],NULL);
}
cout<<"pthread_join end!"<<endl;
return 0;
}
如上图所示,我们可以看到两个线程都拿到了第819张票,这就产生了二义性,即线程不安全现象
这里存在两个问题:
- 线程在取票的时候,多个线程可能会拿到同一张票,,若CPU多的话有可能会拿到负数(后续用互斥锁解决此问题)
- 线程拿票不合理,可能一个线程A拿了所有的票,而另一个线程B只拿了一张票还与线程A相同(二义性问题)
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互
- 多个线程并发的操作共享变量,会带来一些问题
- 互斥量又叫做互斥锁,英文为mutex
让我们来看一段代码:
#include
#include
#include
using namespace std;
int ticket=100;
void* get_ticket(void* arg)
{
int* num=(int*)arg;
while(1)
{
if(ticket>0)
{
sleep(1);
cout<<"thread "<<num<<" get a ticket,no:"<<ticket<<endl;
ticket--;
}
else
{
break;
}
}
return NULL;
}
int main()
{
pthread_t tid[4];
for(int i=0;i<4;i++)
{
pthread_create(tid+1,NULL,get_ticket,(void*)i);
}
for(int i=0;i<4;i++)
{
pthread_join(tid[i],NULL);
}
return 0;
}
看到上面的结果,我们需要思考一个问题:为什么可能无法获得争取结果呢,也就是为什么这里结果为负数了?
if 语句判断条件为真以后,代码可以并发的切换到其他线程
sleep这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
ticket--操作本身就不是一个原子操作
要解决以上的问题,需要做到三点:
代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区
如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区
如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥锁mutex,这就是互斥锁诞生的原因
什么是互斥锁?
- 互斥锁的底层是一个互斥量,而互斥量的本质就是一个计数器,计数器的取值只有两种情况,一种是 1 ,一种是 0
- 1:表示当前临界资源可以被访问
- 0:表示当前临界资源不可以被访问
互斥锁逻辑:
- 调用加锁接口,加锁接口内部计数器的值是否为 1 ,若为 1 ,则能访问;当枷加锁成功之后,就会将计数器的值从 1 变成 0 ;如果为 0 ,则不能访问
- 调用解锁逻辑,计数器的值从 0 变成 1 ,表示资源可以使用
- 假设有一临界资源,有一个线程A和一个线程B,按之前的黄牛抢票的思路,只要线程拥有时间片就可以去访问这块临界资源,现在我们给线程 A 和线程 B 都加上互斥锁,假设此时线程A要去访问临界资源,它首先得获取互斥锁,而此时互斥锁中的值为1,表示当前可以访问,线程 A 去访问临界资源然后将互斥锁中的 1 改为 0 ,此时如果线程B如果想要访问临界资源之前先要获取互斥锁,而此时互斥锁中的值为0,所以线程 B 此时不能访问临界资源,等线程 A 访问完毕后,就会将锁释放,此时所中的值就会从 0 变为 1 , 此时线程 B 判断互斥锁中的值变为 1 可以访问了,就可以去访问临界资源了;互斥锁保证了当前临界资源在同一时刻只能被一个执行流访问
- 注意:若要多个线程访问临界资源的时候是互斥访问的属性,一定要在多个线程中进行同一把锁的加锁操作,这样每个线程在访问临界资源之前都要获取这把锁,若锁中的值为 1 就能访问,为 0 则不能访问;若只给线程 A 加锁线程 B 不加锁,那么线程 A 判断锁中的值为 1 ,则访问临界资源并将锁中的值改为 0 ,而线程 B 为加这把锁,则不需要获取锁并判断锁中的值是否为 1 就可以直接对临界资源进行访问,会出现线程不安全现象
加锁逻辑:
- 加锁的时候会提前在寄存器的计数器中保存的一个值 0,而不管内存的计数器中保存的值为多少,都会将寄存器中保存到值 0 和内存计数器中保存的值进行交互,然后对寄存器中的值进行判断是否为 1 ,如果为 1 ,则能加锁,如果不为 1 ,则不能加锁
- 经过上面的例子,大家已经意识到单纯的i++或者++i都不是原子的,有可能会有数据一致性问题
- 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期
保证原子性原理,如同下图:
我们需要先修改lock与unlock的伪代码
初始化互斥锁接口:
初始化互斥锁有两种方法:静态分配与动态分配
静态分配
//使用宏来静态分配 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- 1
- 2
- 使用vim /usr/include/pthread.h路径下查看宏定义,这个宏中的
PTHREAD_MUTEX_INITIALIZER
是初始化 pthread_mutex_t 这个变量的,如下图所示:
- pthread_mutex_t 实际是一个联合体,
vim /usr/include/bits/pthreadtypes.h
路径,静态初始化实际就是用上面那个宏初始化这个联合体,如下图所示:
动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
- 1
销毁互斥锁接口:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 1
注意:
- 使用静态分配PTHREAD_MUTEX_INITIALIZER初始化的互斥量,不需要销毁
- 不要销毁一个已经加锁了的互斥量
- 已经销毁了的互斥量,要确保后面不会有线程再加锁
加锁与解锁接口:
调用pthread_mutex_lock时可能会遇到下面的情况:
- 互斥量处于未锁状态,该函数将互斥锁锁定
- 发起加锁函数调用时,其它线程已经锁定互斥量或者存在其它线程同时申请加锁互斥量,但是没有竞争到互斥量,那么pthread_mutex_lock会陷入阻塞,等待互斥量被解锁
有了互斥锁知识,我们回头来修改一下在
互斥锁诞生原因章节拿个抢票为负数的问题
,修改后代码如下:
最终效果:
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见于全局变量或者静态变量进行操作,并且没有锁保护的情况下,会导致线程不安全
- 可重入函数:同一函数被不同执行流调用,当前线程还没执行完,就有其它进程进入,我们称之为重入。一个函数在重入的情况下,运行结果不会有任何问题,则该函数称为可重入函数,否则就是不可重入函数
线程安全强调线程,线程执行完是否没有问题,可重入函数强调函数,函数执行完是否没有问题
常见线程不安全情况
- 不保护临界资源
- 返回指向静态变量指针
- 调用不可重入函数
- 函数状态随着被调用,状态发生变化
常见线程安全情况
- 每个线程对于全局会在静态变量只有读权限,没有写权限
- 类或者接口对于线程来说时原子的。
- 多线程切换不会导致结果出现二义性
常见不可重入函数情况
- 调用malloc/free函数,因为malloc是用全局链表管理堆的
- 调用标志I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
总的来说就是一个函数是可重入的,线程是安全的,一个函数不是可重入的,线程不安全
可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的
- 如果将对临界资源的访问加上锁,则这个线程是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的
死锁:是指在一组进程中,各个线程均占有不释放的资源,但因互相申请被其它线程所占用不会释放的资源而处于一种永久等待状态
- 比如:线程A获取到互斥锁1 ,线程B获取到互斥锁2的时候,线程A和线程B同时还想获取对方手里的锁(线程A还想获取互斥锁2,线程B还想获取互斥锁1),此时就会导致死锁
死锁的必要条件意思是,要产生死锁必须拥有的条件,缺1不可
互斥:
一个临界资源每次只能被一个执行流使用请求与保持:
一个执行流因请求资源阻塞时,对以获得的资源保持不放不剥夺:
一个执行流已获得的资源在未使用完前,不能强行剥夺循环等待:
各执行流之间形成头尾相连的循环等待资源的关系
前三个条件是一个正常执行流就有的条件,导致死锁的主要条件就是第四个条件:循环等待
- 破坏死锁的其中一个必要条件即可
- 加锁顺序一致。比如线程1和2都要锁a和b,线程1,2加锁顺序都是先加锁a再加锁b。只有一方再等待
- 避免锁未释放的场景。别的线程就申请不到了
- 资源一次性分配
补充,避免死锁的算法:死锁检测算法、银行家算法
一个线程也可以导致死锁: 自己申请自己的锁
- 当只有互斥的情况下,当一个线程访问某个变量时,在其它线程改变状态之前,这个线程什么也做不了,只能等待
- 例如:一个线程往队列放数据,一个线程往队列读数据。当读数据的线程发现队列为空时,只能等待,直到另一线程往线程里写数据
- 我们发现这样效率是很低下的,我们可以改变一种思路,当线程1发现队列里没数据时,会通知线程2往队列里放数据,当线程2发现队列里的数据满的时候,会通知线程1从队列里读数据,这种思路就叫:同步
同步:再保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题
同步就是在保证数据安全的前提下,让线程按照某种特定的顺序来访问临界资源
同步与互斥的关系:互斥是保证线程的安全,同步在互斥的前提下,提高线程之间的效率
条件变量可以理解成,保存条件的变量。线程可以通过函数来发送或者识别条件变量。来使线程根据条件变量进行不同的动作
条件变量本质是一个PCB等待队列,该等待队列当中存在线程或者进程的PCB
条件变量的初始化:
条件变量的初始化分为:静态初始化、动态初始化
静态初始化:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; //PTHREAD_COND_INITIALIZER是一个宏
- 1
- 2
动态初始化:
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
- 1
条件变量的销毁:
int pthread_cond_destroy(pthread_cond_t *cond);
- 1
- 作用:销毁条件变量
- 参数:cond:要销毁的条件变量
- 返回值:成功返回0,失败返回1
等待条件的满足:
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
- 1
为什么pthread_cond_wait需要互斥量作为参数?
- 首先pthread_cond_wait肯定是因为条件不满足才会进行等待
- 要判断条件一定会在临界区里进行,临界区因为互斥所以是上锁的
- 等待的时候,需要将锁释放,不然别的线程进不了临界区,改变不了条件,就会导致当前线程一直等,导致死锁
- 等待完毕被唤醒时还需要将锁锁上
结论:在调用pthread_cond_wait时,需要别的线程到临界区来修改条件,会自动释放锁。当条件满足被唤醒时,该函数会让该线程重新占有锁
唤醒等待:
int pthread_cond_signal(pthread_cond_t *cond);
- 1
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的
- 解耦
- 支持并发
- 支持忙闲不均
BlockingQueue 在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
下面我们用代码来看看这个模型:
BlockQueue.cpp代码:
#pragma once //防止头文件重复包含 #include
#include #include #include //using namespace std; class Task { public: int _x; int _y; public: Task(){} Task(int x,int y) :_x(x) ,_y(y) {} int run() { return _x+_y; } ~Task(){} }; class BlockQueue { private: //std::queue q; //设置一个队列 std::queue<Task> q; //设置一个队列 int _cap; //容量 pthread_mutex_t lock; //设置一把互斥锁 pthread_cond_t c_cond; //满了的话通知消费者 pthread_cond_t p_cond; //空的话通知生产者 private: //封装起来 void LockQueue() //加锁 { pthread_mutex_lock(&lock); } void UnLockQueue() //解锁 { pthread_mutex_unlock(&lock); } bool IsEmpty() //判断队列是否为空 { return q.size()==0; } bool IsFull() //判断队列是否满了 { return q.size()==_cap; } void ProductWait() //生产者等待 { pthread_cond_wait(&p_cond,&lock); } void ConsumerWait() //消费者等待 { pthread_cond_wait(&c_cond,&lock); } void WakeUpProduct() //唤醒生产者 { std::cout<<"wake up Product..."<<std::endl; pthread_cond_signal(&p_cond); } void WakeUpConsumer() //唤醒消费者 { std::cout<<"wake up Consumer..."<<std::endl; pthread_cond_signal(&c_cond); } public: BlockQueue(int cap) //构造函数初始化 :_cap(cap) { pthread_mutex_init(&lock,NULL); pthread_cond_init(&c_cond,NULL); pthread_cond_init(&p_cond,NULL); } ~BlockQueue() //析构函数销毁 { pthread_mutex_destroy(&lock); pthread_cond_destroy(&c_cond); pthread_cond_destroy(&p_cond); } void put(Task in) { //Queue是临界资源,就要加锁,而且判断是否为满,把接口封装起来 LockQueue(); while(IsFull()) { WakeUpConsumer(); std::cout<<"queue full,notify consume data,product stop!"<<std::endl; ProductWait(); //生产者线程等待 } q.push(in); UnLockQueue(); } void Get(Task& out) { LockQueue(); while(IsEmpty()) { WakeUpProduct(); std::cout<<"queue empty,notify product data,consumer stop"<<std::endl; ConsumerWait(); } out=q.front(); q.pop(); UnLockQueue(); } //线程接口函数 /*void* Product(void* arg) { } void* Consumer(void* arg) { }*/ };
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
- 100
- 101
- 102
- 103
- 104
- 105
- 106
- 107
- 108
- 109
- 110
- 111
- 112
- 113
- 114
- 115
- 116
- 117
- 118
- 119
- 120
- 121
- 122
- 123
- 124
- 125
- 126
- 127
- 128
- 129
- 130
- 131
- 132
- 133
- 134
- 135
- 136
- 137
main.cpp代码:
#include"BlockQueue.cpp" #include
using namespace std; pthread_mutex_t p_lock; pthread_mutex_t c_lock; void* Product_Run(void* arg) { BlockQueue* bq=(BlockQueue*)arg; srand((unsigned int)time(NULL)); while(true) { pthread_mutex_lock(&p_lock); // int data=rand()%10+1; int x=rand()%10+1; int y=rand()%100+1; Task t(x,y); bq->put(t); pthread_mutex_unlock(&p_lock); cout<<"product data is:"<<t.run()<<endl; } } void* Consumer_Run(void* arg) { BlockQueue* bq=(BlockQueue*)arg; while(true) { pthread_mutex_lock(&c_lock); // int n=0; Task t; bq->Get(t); pthread_mutex_unlock(&c_lock); cout<<"consumer is:"<<t._x<<"+"<<t._y<<"="<<t.run()<<endl; sleep(1); } } int main() { BlockQueue* bq=new BlockQueue(10); pthread_t c,p; pthread_create(&c,NULL,Product_Run,(void*)bq); pthread_create(&p,NULL,Consumer_Run,(void*)bq); pthread_join(c,NULL); pthread_join(p,NULL); delete bq; return 0; }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
makefile代码:
main:main.cpp g++ $^ -o $@ -lpthread .PHONY:clean clean: rm -f main
- 1
- 2
- 3
- 4
- 5
POSIX信号量的本质一个描述临界资源有限个数的计数器!
POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步
POSIX信号量初始化:
#include
//头文件 int sem_init(sem_t sem, int pshared, unsigned int value);//函数原型
- 1
- 2
参数:
- pshared:0表示线程间共享,非零表示进程间共享
- value:信号量初始值
POSIX信号量的销毁:
int sem_destroy(sem_t sem);
POSIX信号量的等待:
//功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t sem);
POSIX信号量的发布:
//功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t sem);
上一个生产者-消费者的例子是基于queue的,其空间可以动态分配,现在基于固定大小的环形队列重写这个程序(POSIX信号量)
让我们来看看代码实现:
RingQueue.cpp代码:
#pragma once
#include
#include
#include
#include
#include
#define NUM 10
class RingQueue
{
private:
std::vector<int> v;
int _cap; //容量
sem_t sem_blank; //生产者
sem_t sem_data; //消费者
int c_index; //消费者索引
int p_index; //生产者索引
public:
RingQueue(int cap=NUM)
:_cap(cap)
,v(cap)
{
sem_init(&sem_blank,0,cap);
sem_init(&sem_data,0,0);
c_index=0;
p_index=0;
}
~RingQueue()
{
sem_destroy(&sem_blank);
sem_destroy(&sem_data);
}
void Get(int& out)
{
sem_wait(&sem_data);
//消费
out=v[c_index];
c_index++;
c_index=c_index%NUM; //防止越界,构成环形队列
sem_post(&sem_blank);
}
void Put(const int& in)
{
sem_wait(&sem_blank);
//生产
v[p_index]=in;
p_index++;
p_index=p_index%NUM;
sem_post(&sem_data);
}
};
main.cpp代码:
#include"RingQueue.h"
using namespace std;
void* Consumer(void* arg)
{
RingQueue *bq=(RingQueue*)arg;
int data;
while(1)
{
bq->Get(data);
cout<<"i am:"<<pthread_self()<<" i consumer:"<<data<<endl;
}
}
void* Product(void* arg)
{
RingQueue* bq=(RingQueue*)arg;
srand((unsigned int)time(NULL));
while(1)
{
int data=rand()%100;
bq->Put(data);
cout<<"i am:"<<pthread_self()<<" i product:"<<data<<endl;
sleep(1);
}
}
int main()
{
RingQueue* pq=new RingQueue();
pthread_t c;
pthread_t p;
pthread_create(&c,NULL,Consumer,(void*)pq);
pthread_create(&p,NULL,Product,(void*)pq);
pthread_join(c,NULL);
pthread_join(p,NULL);
return 0;
}
Makefile代码:
main:main.cpp
g++ $^ -o $@ -lpthread
.PHONY:clean
clean:
rm -f main
结果:
什么是线程池?简单点说,线程池就是有一堆已经创建好了的线程,初始它们都处于空闲等待状态,当有新的任务需要处理的时候,就从这个池子里面取一个空闲等待的线程来处理该任务,当处理完成了就再次把该线程放回池中,以供后面的任务使用。当池子里的线程全都处理忙碌状态时,线程池中没有可用的空闲等待线程,此时,根据需要选择创建一个新的线程并置入池中,或者通知任务线程池忙,稍后再试
线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量
- 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了
- 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误
- 比如:创建固定数量线程池,循环从任务队列中获取任务对象,获取到任务对象后,执行任务对象中的任务接口
Thread_Pool.h:
#include
#include
#include
#include
#include
#include
#define NUM 5
class Task
{
private:
int _b;
public:
Task(){}
Task(int b)
:_b(b){}
~Task(){}
void Run()
{
std::cout<<"i am:"<<pthread_self()<<" Task run.... :base# "<<_b<<" pow is "<<pow(_b,2)<<std::endl;
}
};
class ThreadPool
{
private:
std::queue<Task*> q;
int _max_num; //线程总数
pthread_mutex_t lock;
pthread_cond_t cond; //只能让消费者操作
private:
void LockQueue()
{
pthread_mutex_lock(&lock);
}
void UnLockQueue()
{
pthread_mutex_unlock(&lock);
}
bool IsEmpty()
{
return q.size()==0;
}
bool IsFull()
{
return q.size()==_max_num;
}
void ThreadWait()
{
pthread_cond_wait(&cond,&lock); //等待条件变量满足
}
void ThreadWakeUp()
{
pthread_cond_signal(&cond);
}
public:
ThreadPool(int max_num=NUM )
:_max_num(max_num){}
static void* Routine(void* arg)
{
while(1)
{
ThreadPool *tp=(ThreadPool*)arg;
while(tp->IsEmpty())
{
tp->LockQueue(); //静态成员方法不能访问非静态成员方法,所以传(void*)this传过去
tp->ThreadWait(); //为空挂起等待
}
Task t;
tp->Get(t); //获取这个任务
tp->UnLockQueue();
t.Run(); //拿到这个任务运行
}
}
void ThreadPoolInit()
{
pthread_mutex_init(&lock,NULL);
pthread_cond_init(&cond,NULL);
int i=0;
pthread_t t;
for(i=0;i<_max_num;i++)
{
pthread_create(&t,NULL,Routine,(void*)this);
}
}
~ThreadPool()
{
pthread_mutex_destroy(&lock);
pthread_cond_destroy(&cond);
}
//server 放数据
void Put(Task& in)
{
LockQueue();
q.push(&in);
UnLockQueue();
ThreadWakeUp();
}
//ThreadPool 取数据
void Get(Task& out)
{
//线程池里面直接拿不用加锁
Task* t=q.front();
q.pop();
out=*t;
}
};
main.cpp代码:
#include"Thread_Pool.h"
using namespace std;
int main()
{
ThreadPool *tp=new ThreadPool();
tp->ThreadPoolInit();
while(true)
{
int x=rand()%10+1;
Task t(x);
tp->Put(t);
sleep(1);
}
return 0;
}
Makefile代码:
main:main.cpp
g++ $^ -o $@ -lpthread
.PHONY:clean
clean:
rm -f main
单例模式是一种创建型模式,它会限制应用程序,使其只能创建某一特定类>类型的一个单一的实例。举例来说,一个web站点将会需要一个数据库连接>对象,但是应该有且只能有一个,因此我们通过使用单例模式来实现这种限>制。我们可以使用一个静态属性来保证对于一个特定的类来说只存在一个单一的>实例
简言之:某些类, 只应该具有一个对象(实例), 就称之为单例
举个例子:洗碗
- 吃完饭, 立刻洗碗, 这种就是饿汉方式。 因为下一顿吃的时候可以立刻拿着碗就能吃饭
template <typename T> class Singleton { private: static T data; //定义静态的类对象,程序加载类就加载对象 public: static T* GetInstance() { return &data; } };
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
存在一个严重的问题, 线程不安全,第一次调用 GetInstance 的时候, 如果两个线程同时调用, 可能会创建出两份 T 对象的实例。但是后续再次调用, 就没有问题了
举个例子:还是洗碗
- 吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式
class Singleton { static T* inst; //定义静态的类对象指针,程序运行时才加载对象 public: static T* GetInstance() { if (inst == NULL) { inst = new T(); } r eturn inst; } };
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
存在一个严重的问题, 线程不安全,第一次调用 GetInstance 的时候, 如果两个线程同时调用, 可能会创建出两份 T 对象的实例。但是后续再次调用, 就没有问题了
template <typename T>
// 懒汉模式, 线程安全
template <typename T>
class Singleton {
volatile static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.
static std::mutex lock;
public:
static T* GetInstance()
{
if (inst == NULL) // 双重判定空指针, 降低锁冲突的概率, 提高性能. //判断两个线程不同时进去直接return
{
lock.lock(); // 使用互斥锁, 保证多线程情况下也只调用一次 new. //两个线程同时进去加锁
if (inst == NULL)
{
inst = new T();
}
lock.unlock();
}
return inst;
}
};
注意事项:
- 加锁解锁的位置
- 双重 if 判定, 避免不必要的锁竞争
- volatile关键字防止过度优化
学了线程安全,我们可以思考下STL智能指针是否是线程安全的呢?
答案当然不是!
原因: STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响,而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶)。因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全
悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起
乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作
CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试