Linux--多线程(二)
线程的同步和互斥
基本概念
概述:现在操作系统基本都是多任务的操作系统,同时有大量可以调度的实体在运行。在多任务操作系统当中,同时运行的多个任务可能:
- 都需要访问/使用同一种资源
- 多个任务之间有依赖关系,某个任务的运行依赖于另一个任务
同步和互斥就是用来解决上述两个问题的。
同步和互斥的概念:
- 互斥是要求两个任务不能同时占用资源,会相互排序,必须等待一个线程运行完毕,另外一个线程才能过来使用资源。
- 同步是一种更为复杂的互斥,在互斥的基础上,要求两个任务的执行存在先后顺序。
其他相关概念:
- 临界资源: 多线程执行流共享的资源就叫做临界资源
- 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态(无中间态,即使被打断,也不会受影响),要么完成,要么未完成
互斥量mutex
概念: 多个线程对一个共享变量进行操控时,会引发数据不一致的问题。此时就引入了互斥量(也叫互斥锁)的概念,来保证共享数据操作的完整性。在被加锁的任一时刻,临界区的代码只能被一个线程访问。
互斥锁是一种简单的加锁的方法来控制对共享资源的访问,互斥锁只有两种状态,即加锁(lock)和解锁(unlock)。
代码的要求:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
互斥量的接口
互斥量其实就是一把锁,是一个类型为pthread_mutex_t
的变量,使用前需要进行初始化操作,使用完之后需要对锁资源进行释放。
- 初始化互斥量
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
功能:
初始化一个互斥锁
参数:
mutex:互斥锁地址,类型是pthread_mutex_t
attr:设置互斥量的属性,通常可采取默认属性,即可将attr改为NULL
可以使用宏pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER静态初始化互斥锁
这种方法等价于使用NULL指定的attr参数调用pthread_mutex_init()来完成动态初始化,不同之处在于PTHREAD_MUTEX_INITIALIZER宏不进行错误检查
返回值:
成功:0 成功申请的锁默认是打开的
失败:非0 错误码
注意:restrict是C语言中的一种类型限定符,用于告诉编译器,对象已经被指针引用,不能通过除该指针外所有其他直接或者间接的方式修改该对象的内容。
- 加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
功能:
对互斥锁上锁,若互斥锁已经上锁,则调用者阻塞,直到互斥锁解锁后再上锁。
参数:
mutex:互斥锁地址。
返回值:
成功:0
失败:非0错误码
int pthread_mutex_trylock(pthread_mutex_t *mutex);
调用该函数时,若互斤锁未加锁,则上锁,返回0;
若互斥锁已加锁,则函数直接返回失败,即EBUSY
- 解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
功能:
对指定的互斥锁解锁
参数:
mutex:互斥锁地址
返回值:
成功:0
失败:非0错误码
- 销毁互斥量
int pthread_mutex_destroy(pthread_mdtex_t *mutex);
功能:
销毁指定的一个互斥锁。互斥锁在使用完毕后,必须要对互斥锁进行销毁,以释放资源
参数:
mutex:互斥锁地址
返回值:
成功:0
失败:非0错误码
注意:
- 使用
PTHREAD_ MUTEX_ INITIALIZER
初始化的互斥量不需要销毁 - 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
- 加锁的粒度要够小
代码示例:写了一个抢票的小程序,用全局变量ticket
代表现有票数,五个线程分别执行抢票的操作,也就是对ticket
进行减减的操作,直到票数为0就停止抢票
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t mutex;// 创建锁变量
//全局变量,所有线程共享
int ticket = 10;
void* get_tickets(void* arg)
{
long id = (long)arg;
while (1){
usleep(1000);
// 加锁
pthread_mutex_lock(&mutex);
if (ticket > 0){
// 有票
--ticket;
printf("线程%ld获得一张票,剩余%d张票\n",id,ticket);
// 解锁
pthread_mutex_unlock(&mutex);
}else{
// 无票,退出
// 解锁
pthread_mutex_unlock(&mutex);
break;
}
}
}
int main()
{
pthread_t t[5];
// 初始化锁
pthread_mutex_init(&mutex, NULL);
// 创建5个线程
long i = 0;
for (; i < 5; ++i)
{
pthread_create(t+i, NULL, get_tickets, (void*)(i+1));
}
// 释放5个线程
for (i = 0; i < 5; ++i)
{
pthread_join(t[i], NULL);
}
// 销毁锁
pthread_mutex_destroy(&mutex);
return 0;
}
运行结果如下:
总结几点并回答几个问题:
锁的作用: 对临界区进行保护,所有的执行流线程都必须遵守这个规则:lock——>访问临界区——>unlock
需要注意的点:
- 所有的线程必须看到同一把锁,锁本身就是临界资源,所以锁本身需要先保证自身安全申请锁的过程不能出现中间态,必须保证原子性
- 任一线程持有锁之后,其它线程如果还想申请锁时申请不到的,保证互斥性
线程申请不到锁此时会做什么?
进入等待队列进行等待,从运行队列转移到等待队列,状态由R变成S,持有锁的线程unlock之后,需要唤醒等待队列中的第一个线程
struct mutex
{ int lock;// 0 1
// ...
sturct wait_queue;//锁下的等待队列
}
互斥量的原理
大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
下面是lock和unlock的伪代码
lock:
movb $0, %a1 # 把0值放进寄存器a1里
xchgb %a1, mutex # 交换a1寄存器的内容和锁的值(无线程使用锁时,metux的值为1)
if (%a1 > 0)
return 0; # 得到锁
else
挂起等待;
goto lock;
unlock:
movb $1 mutex #把1赋给锁
唤醒等待的线程;
return 0;
在上述加锁的伪代码中演示了上步骤:
- 对寄存器的内容进行清0
- 把mutex的值(被使用值为0,未被使用值为1)和寄存器的内容进行交换
- 寄存器的内容为1代表得到了锁,为0代表未得到锁,要挂起等待
解锁的伪代码步骤(只有有锁的线程才可以执行到这段代码):
- 把mutex的值改为1
- 唤醒等待锁的线程
死锁
概念: 死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
举个例子:
这里线程1先申请资源1,申请到了之后,资源1被锁死(资源1会永远被线程1申请,因为只有申请到资源2执行完临界代码,才会释放掉资源1,此时线程1被卡在申请资源2的点,根本走不到释放资源1的代码,所以会一直被线程1占有),线程2无法申请,线程2先申请资源2,同样资源2也被锁死,这样当线程1继续向下申请资源2的时候,就被阻塞在那里,线程2在向下申请资源1的时候,也被阻塞在那里,这就形成了死锁,永远解不了锁。
死锁引起的原因:
- 竞争不可抢占资源引起死锁:这就是上述情况,都在等待对方占有的不可抢占的资源
- 竞争可消耗资源引起的死锁:有p1,p2,p3三个进程,p1向p2发送消息并接受p3发送的消息,p2向p3发送消息并接收p1的消息,p3向p1发送消息并接收p2的消息,如果设置时先接收消息后发送消息,则所有的信息都不能发送,这就造成死锁
死锁产生的四个必要条件:
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁:
- 破坏请求和保持条件
- 协议1:所有进程开始前,必须一次性地申请所需的所有资源,这样运行期间就不会再提出资源的需求,破坏了请求条件,即使有一种资源不能满足需求,也不会给它分配正在空闲的资源,这样它就没有资源,就破坏了保持条件,从而预防死锁
- 协议2:允许一个进程只获得初期的资源就开始运行,然后再把运行完的资源释放出来,然后再请求新的资源
- 破坏不可抢占条件
- 当一个已经保持了某种不可抢占资源的进程,提出新资源请求不能被满足的时候,它必须释放已经保持的所有资源,以后需要的时候再申请
- 破坏循环等待条件
- 对系统中的所有资源类型进行线性排序,然后规定每个进程必须按序列号递增的顺序请求资源。加入进程请求到了一些序列号较高的资源,然后请求一个序列号较低的资源时,必须先释放相同的更高序号的资源后才能申请低序列号的资源,多个同类资源必须一起请求
- 将所有资源进行线性排序,每个进程申请资源的顺序保持一致
实例演示:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
//线程的两个互斥量
pthread_mutex_t mutex1;
pthread_mutex_t mutex2;
//线程1处理函数
void *fun1(void *arg)
{
//线程1先申请资源1,再申请资源2
//加锁
pthread_mutex_lock(&mutex1);
printf("线程1加锁资源1ok....\n");
pthread_mutex_lock(&mutex2);
printf("线程1加锁资源2ok....\n");
printf("线程1执行临界代码");
//解锁
pthread_mutex_unlock(&mutex1);
pthread_mutex_unlock(&mutex2);
return NULL;
}
//线程2处理函数
void *fun2(void* arg)
{
//线程2先申请资源2,再申请资源1
//加锁
pthread_mutex_lock(&mutex2);
printf("线程2加锁资源1ok....\n");
pthread_mutex_lock(&mutex1);
printf("线程2加锁资源2ok....\n");
printf("线程2执行临界区代码....\n");
//解锁
pthread_mutex_unlock(&mutex2);
pthread_mutex_unlock(&mutex1);
return NULL;
}
//演示死锁
int main()
{
int ret = -1;
int ret1 = -1;
pthread_t tid1,tid2;
//初始化互斥量
pthread_mutex_init(&mutex1,NULL);
pthread_mutex_init(&mutex2,NULL);
//创建两个线程
pthread_create(&tid1,NULL,fun1,NULL);
pthread_create(&tid2,NULL,fun2,NULL);
//回收资源
ret = pthread_join(tid1,NULL);
ret = pthread_join(tid2,NULL);
if(0!=ret)
{
printf("线程1资源回收失败\n");
return 1;
}
if(0!=ret1)
{
printf("线程2资源回收失败\n");
return 1;
}
//销毁互斥锁
pthread_mutex_destroy(&mutex1);
pthread_mutex_destroy(&mutex2);
return 0;
}
运行结果如下:
两个进程都想获得对方的锁,造成死锁。
条件变量
概念
利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待"条件变量的条件成立"而挂起;另一个线程使“条件成立”(给出条件成立信号)。为了防止竞争,条件变量的使用总是和一个互斥锁结合在一起。
同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而避免饥饿问题,叫做同步
为什么存在线程同步?
线程同步使得每个线程都能够访问临界资源,多个线程协同高效完成某些任务。
条件变量如何与互斥锁结合使用?
条件变量是包含一个等待队列的。多个线程可以去竞争一把锁,没有得到锁资源的线程会在锁上继续挂起等待,当拥有锁的线程条件变量满足时,会先释放锁资源,然后进入到条件变量的等待队列去等待(等待其他线程唤醒),这样其他线程就可以获得锁资源,如果此时唤醒的条件变量满足,该线程可以去唤醒等待队列中的第一个线程,自己释放锁资源,然后让第一个线程重新拥有锁资源,依次如此,多个线程就是顺序地执行工作。这样就可以实现线程同步的操作。
与互斥锁不同的是,条件变量是用来等待而不是用来上锁的,条件变量本身就不是锁!
条件变量用来自动阻塞一个线程,直到某种特殊情况发生为止,通常和互斥锁一起使用。
条件变量的两个动作:
- 条件不满,阻塞线程
- 条件满足,通知阻塞的线程开始工作
条件变量的类型:pthread_cond_t
条件变量的接口
条件变量是一个类型为pthread_cond_t
的条件变量,课通过定义变量的方式来定义一个条件变量
- 条件变量初始化
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
功能:
初始化一个条件变量
参数:
cond:指向要初始化的条件变量指针
attr:条件变量属性,通常为默认值,传入NULL即可
也可以使用静态初始化的方法,初始化条件变量:pthread_cond_t cond = PTHREAD_COND_INITIALIZER
返回值:
成功:0
失败:非0错误号
- 条件变量的销毁
int pthread_cond_destroy(pthread_cond_t *cond);
功能:
销毁一个条件变量
参数:
cond:指向要始化的条件变量指针
返回值:
成功:0
失败:非0错误号
- 等待条件变量满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
功能:
阻塞等待一个条件变量
a)阻塞等待条件变量cond(参1)满足
b)释放已掌握的互斥锁(解锁互斥量)相当于pthread_mutex_unlock(&mutex);
a)b)两步为一个原子操作
c)当被唤醒,pthread_cond_wait函数返回时,解除阻塞并重新申请获取互斥锁pthread_mutex_lock(&mutex);
参数:
cond:指向要初始化的条件变量指针
mutex:互斥锁
返回值:
成功:0
失败:非0错误号
为什么pthread_cond_wait需要互斥量?
条件变量是实现线程同步的一种手段,如果一个线程进入等待队列还不释放锁资源,这样其他线程也不能够得到锁资源,这样唤醒线程的条件变量永远不可能满足,那么这个线程也将一直等待下去。所以一个线程进入等待队列需要释放自己手中的锁资源来实现真正地同步
- 唤醒条件变量
int pthread_cond_signal(pthread_cond_t *cond)
功能:
唤醒阻塞队列上的第一个线程
参数:
cond指向要初始化的条件变量指针
返回值:
成功:0
失败:非0错误号
int pthread_cond_broadcast(pthread_cond_t *cond)
功能:
唤醒全部阻塞在条件变量上的线程
参数:
cond:指向要初始化的条件变量指针
返回值:
成功:0
失败:非0错误号
后者是唤醒等待队列中所有的线程,而前者只唤醒等待队列中的第一个线程。后者会带来一个很不好的效应——惊群效应。多个线程同时被唤醒,但是最终只有一个线程能够获得“控制权”,其他获得控制权失败的线程可能重新进入休眠状态。等待获得控制权的线程释放锁资源后去通知下一个线程,这样就容易引起OS和CPU的管理调度负担,所以不建议使用。
实例演示: 创建五个线程,四个线程执行run1,上来就在条件变量下等待,另一个线程执行run2,然后无脑唤醒等待队列下的线程
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
//创建条件变量
pthread_cond_t cond;
//创建互斥锁
pthread_mutex_t mutex;
//线程处理函数1
void *threadfun1(void *arg)
{
char* name = (char*)arg;
while(1)
{
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond,&mutex);
printf("%s is waked up...\n",name);
sleep(1);
pthread_mutex_unlock(&mutex);
}
}
//线程处理函数2
void *threadfun2(void *arg)
{
char *name = (char *)arg;
while(1)
{
sleep(1);
//唤醒一个等待队列中的线程
pthread_cond_signal(&cond);
printf("%s is wakeding up a thread...\n",name);
}
}
int main()
{
pthread_t pthread1,pthread2,pthread3,pthread4,pthread5;
//初始化条件变量
pthread_cond_init(&cond,NULL);
//初始化互斥锁
pthread_mutex_init(&mutex,NULL);
//创建五个线程
pthread_create(&pthread1,NULL,threadfun1,(void *)"pthread 1");
pthread_create(&pthread2,NULL,threadfun1,(void *)"pthread 2");
pthread_create(&pthread3,NULL,threadfun1,(void *)"pthread 3");
pthread_create(&pthread4,NULL,threadfun1,(void *)"pthread 4");
pthread_create(&pthread5,NULL,threadfun2,(void *)"pthread 5");
//等待线程结束
pthread_join(pthread1,NULL);
pthread_join(pthread2,NULL);
pthread_join(pthread3,NULL);
pthread_join(pthread4,NULL);
pthread_join(pthread5,NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}
运行结果如下:
值得注意的是pthread_cond_wait在阻塞的时候,会释放已经掌握的互斥锁,等到被唤醒的时候,重新上锁。
举个例子:
其实pthread_cond_wait内部隐藏一次解锁的过程,如果是fun1先运行,num被上锁,会阻塞在第24条语句,但是pthread_cond_wait会先解锁,释放掉num资源,但依然阻塞在24行,此时fun2加锁,改变条件,函数pthread_cond_signal会唤醒pthread_cond_wait函数,此时num会再次被上锁,然后解锁,所以pthread_cond_wait其实在内部做了一次解锁的操作。
条件变量其实很简单,遇到pthread_cond_wait线程就会阻塞在阻塞队列,当pthread_cond_signal调用的时候,就会唤醒在阻塞队列中的线程,继续执行下面的代码。