前言:在操作系统中,进程是资源分配的基本单位,那么线程是什么呢?线程是调度的基本单位,我们该怎么理解呢?
目录
现在我们举一个例子:我们以家庭为单位,将家庭看作一个整体,假如分配房子,车子等社会资源,这些资源被分配是以家庭为单位,这就类似于进程,每个进程执行一个大任务,进程之间的联系并不紧密并且相对独立。线程则类似与家庭里面的每一个人,家里面有许多为了完成大任务而拆分出来的小任务,比如孩子要上学,父母要上班,还有家务,家庭里面的每一个人有关联但也有各自不同的小任务,家庭里面的每一个人都共享所有资源,比如电视剧,客厅,车子——进程被分配的资源,但是同时它们也有自己的私人空间,线程同样如此,但线程的私人空间是
大家可能很好奇为什么有进程了还要有线程,一个CPU一个时间段执行一个指令吗?线程虽称并行执行流,但底层还是不能同时执行,也得排队。
现在举一个例子:有一个进程A需要执行一个任务,从外设输入字符串并且打印到屏幕上,并且要计算一些加减乘除。
如果只有一个进程我们只能顺序执行,也就是当输入输出的时候我们不能干其他事必须等待,而IO流的速度是很慢的,如果我们一直等待不就把CPU的资源浪费了吗?如果我们利用线程呢?一个线程负责IO流,一个线程负责计算,这样子当我们进行IO操作的时候,我们可以把它挂起,利用CPU去进行计算,当IO操作完成再唤醒执行这个线程,这样子CPU的利用率就提高了,执行效率也提高了。
但是有人可能有疑问,我们为什么不切换到下一个进程,等到IO操作完成再唤醒这个进程呢?这就涉及到了开销的问题,进程之间是相互独立的,我们切换是需要进行保存现场等工作,这样子不断的切换开销是很大的,而线程它们之间的切换的开销小的很多,他们的页表,数据都是共享的。
那么线程适合什么样的场景使用,线程是越多越好吗?
线程适合IO操作较多的场景,计算流操作效果比较差,因为CPU的算力是有限的,同一时间只允许一个计算任务。
线程并不是越多越好,因为线程也是有开销的,例如TCB结构体,栈,对这些进行管理也要开销。
在Linux里面实际上是没用线程的,只有轻量级进程,但是为了迎合主流,Linux还是对轻量级进程进行了包装成了线程库,因此在编译时要连接原生线程库,在g++指令后面加上 -lpthread,例如:
g++ -o test.o test.cpp -std=c++11 -lpthread
pthread_t
底层实际上就是一个unsigned int
第一个参数thread会通过指针返回创建线程的ID,第二个参数大部分情况是NULL,用来调整线程的属性,第三个参数是线程执行的函数,第四个参数是执行函数的参数。成功返回0,失败返回错误编号。
- void* task(void* arg){
- int* i=(int*)arg;
- cout<<"这是一个线程任务:"<<*i;
- }
- pthread_t tid;
- int i=1;
- pthread_create(&tid,NULL,task,(void*)i);
没有参数,返回值就是本线程的ID。
- pthread_t tid=pthread_self();
- pthread_join(tid,NULL);
- pthread_t tid=pthread_self();
- pthread_cancel(tid);
这个作用于pthread_exit作用效果类似,但是只能退出本线程,不能退出指定线程。
大家有没有想过多线程执行会不会带来安全问题,答案是必然的,为什么呢?因为线程简单切换会发生在如何不是原子代码执行的时候(原子性是指代码执行不会被中断,要么不开始,一开始就必须执行完,不存在中间状态),
- int tictik=100;
- void* RobTictik(void* arg){
- while(titck>0){
- cout<<"线程:"<<pthread_self()<<"抢到了票"<
- }
- }
如果所有线程执行这个函数,很有可能就会出现tictik最后小于0的情况,也就是最后卖出了多于100张的票,为什么呢?假如tictik已经是1了,线程A刚刚进入还未来得及打印将tictik打印就切换到了线程B,就会出现多卖票的情况。
那我们有什么办法解决吗?
1,互斥锁的原理
互斥锁是什么呢?人如其名它的功能类似于一把锁,你进去时候加上一把锁,当别人试图进来的时候就会因为没有钥匙而无法进来,你出去的是就把锁换回去,让其他想进来的人竞争这把钥匙。
锁的原理是什么呢?其实挺简单的,就是锁里面本来有一个1,当线程切换的时候线程会把自己的上下文保存,将数据1拿走,其他线程走到这块区域的时候就发现是0无法运行,继续等待抢锁,直到线程将这块区域运行完才会将锁换回去。下面这个图就是类似我讲述的锁原理。
2,互斥锁的使用
互斥锁的使用需要初始化,然后加锁,解锁。
初始化有两种方式,一种是全局锁,一种是局部锁(作用域)
这是互斥锁的结构体
全局互斥锁初始化
局部互斥锁的初始化
第一个参数是锁结构体,第二个参数一般填NULL。
加锁
成功返回0,需要注意的是加锁代码是原子性的,防止多个线程进入锁
解锁
成功返回0,注意解锁并不是原子性的,因为解锁时是不是原子性已经不重要了,如果锁已经归还,多线程也只能有一个抢到,如果还未归还不过是让其他线程多等等。
- pthread_mutex_init(&_mutex,NULL);
- pthread_mutex_lock(&p->_mutex);
- //临界区代码,被保护,原子性
- pthread_mutex_unlock(&p->_mutex);
3,锁带来的饥饿问题
互斥锁的抢夺是公平的,但是有一些线程的抢锁能力强,这就会导致一个问题,一个线程长期霸占着锁,其他线程就一直无法运行代码,导致饥饿问题,那有什么解决办法吗?答案是条件变量。
条件变量是什么呢?之前我们举例子所有人抢钥匙开门,现在我们加一个规矩,那就是排队,新来的和出去的只能从后面开始排队,而且这段时间你们都处于休眠,直到轮到你们有人唤醒你们才继续执行。
条件变量使用很类似于互斥锁
条件变量结构体
初始也分全局初始化和局部初始化
全局条件变量初始化
局部条件变量初始化
第一个参数是条件变量结构体,第二个参数一般是NULL。
互斥锁的使用一般是放在互斥锁里面的,如果将线程放入条件队列,会先解锁,然后继续抢锁,因此建议进入互斥锁临界区就先检查是否需要放入条件队列等待
参数一是条件变量结构体,参数二是互斥锁,因为条件变量是需要结合互斥锁使用的。
条件变量的唤醒,我们直到进入条件变量等待队列后是无法自己醒来的,需要使用函数唤醒
唤醒指定条件变量里面的一个线程,成功返回0
唤醒指定条件变量里面的所有线程,成功返回0
破坏条件变量
- pthread_mutex_init(&_mutex,NULL);
- pthread_cond_init(&cond,NULL);
- pthread_mutex_lock(&p->_mutex);
- while(条件不满足){
- pthread_cond_wait(&cond,&mutex);
- }
- //临界区代码,被保护,原子性
- pthread_mutex_unlock(&p->_mutex);
上面的代码为什么要用循环来判断条件是否满足呢?因为即使抢到锁了条件也不一定满足,如果是if语句就会直接执行接下里的代码,导致线程安全问题
4,信号量
在Linux里面信号量也是保护线程安全的一种重要手段,一般也是结合互斥锁使用
信号量的原理就是计数器,但是对计数器的操作是原子性的,举个例子,假如盆里面有十个苹果,有三个人都想抢苹果,三个让可以同时拿苹果,但是不能抢同一个苹果,信号量就是保护你们不抢同一个苹果。
信号量结构体
信号量的初始化只有一种
第二个参数一般设置为0,第三个参数是sem量的初始值,类似于上面的盆子里有几个苹果,成功返回0。
申请信号量,也就是上面申请抢一个苹果,成功返回0.
释放信号量,相当于有人往盆里放苹果,成功返回0。
六,线程安全条件
什么样的线程有风险,什么样的线程是安全的呢?
1,常见的线程安全情况
只读不写
执行流里面的写操作都是原子性的
多个线程切换不存在二义性
2,常见的线程不安全情况
不保护多线程共享的变量
执行流的状态随着执行,被调用状态发生变化
返回指向静态变量的函数
调用线程不安全的函数
3,死锁
死锁是指各自不释放自己占有有资源,但因为有资源抢夺不到而都无法导致一种尴尬的场景。举个例子,想要打开一个宝箱需要两个要是,有两个人各自持有一把锁(线程各自持有一个锁),双方互不相让,导致谁也打不开宝箱,死锁和多个锁之间分配顺序的不同有很大关系。
死锁但是有四个必要的条件
1,不可剥夺性,线程占有资源互不相让,别人无法强行抢夺自己以有的资源
2,互斥条件 ,一个资源不能同时被多个人使用
3,请求和保持条件,一个执行流因请求资源而阻塞时,对已获得的资源保持不放
4,循环等待条件,形成了环路,造成了尴尬的场面,谁也无法好过。
如何避免死锁
破坏上面的四个形成的必要条件之一,死锁就不攻自破
加锁顺序一致,防止各自持有对方所需的资源
避免锁未释放,资源被锁死
资源一次性释放
银行家算法:模拟资源分配,如果产生了死锁就撤销任务不分配资源
死锁检测算法
拓展:C++里面的各种STL容器为了追求效率是没用加锁的,使用的时候要注意线程安全。